UTXO-Based Actor Model Design and Nested Invocation Intermediate Representation

基于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是计算的基本单元,它体现为三件事:

  1. 信息处理(计算)。
  2. 存储(状态)
  3. 通信

本方案将借助 Actor模型的思路来论述,每个 dApp 都接收来自用户的意图,以及来自其他 dApp 的请求,同时也可以给其他 dApp 来发送请求,但是由于 UTXO 并非链上计算系统,而是链下确定性的,亦即链上的 inputs 和 outputs 只有最初和最终的状态,而没有中间状态。

本方案将试图解决这个问题,通过在Witness 内固化整个Actor通信流程。

首先对本方案的应用进行定义,每个应用大体上会包含以下Cell:

  1. Configs Cell:
    • data内放置整个应用依赖的配置
    • typescript 符合 type_id 规则,以保证全局唯一。
    • Lock 决定了谁能修改configs,也可以将lock设置为always-success,在 configs cell 的 typescript 里控制修改。
  2. 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 里控制修改。
  3. Asset Cell:如 UDT Cell,DOB Cell,Capacity Cell
    1. Data 一般由资产 type 所决定。
    2. type script:定义资产类型
    3. lock:proxy lock,一般为 input_type_proxy,即通过判断 inputs 中是否存在对应的 type script 来判定是否解锁,而对应 Actor Cell 的 type script 则负责检查 Asset Cell 的前后变化是否符合规则。
  4. Data Cell:用于存储某些长度不定的数据, 如类似可追加数组,key-value字典等。
    1. Data 一般存储需要的data。
    2. type script:定义数据类型
    3. lock:可以用lock控制数据修改,也可以将lock设置为alway-success,把权限管理整合进唐type。
  5. 示例:
    1. 基于链表的 Data Cell,通过其type判定其确实是Actor Cell所存储的数据,直接从 cellDeps,或者inputs中读取数据使用。
    2. 基于累加器的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
      • IntentProcessTraceintent_index 代表了该 trace 与第几个 intent 的处理对应。由于 Actor 可能作为被调方参与其他 intent 的处理,intent_index 可能不连续。
    • 找到针对自身的 IntentProcessTraces:
      • 在所有的 IntentProcessTrace 中,找到 invocations 列表中 target_script_hash 为自身且 receiver_script_hash 非自身的 CrossCellInvocation
      • 这些代表了其他 Actor 对自身的调用,其中target_index代表这个intent在自己处理序列中位置。
    • 一致性检查:
      • 在同一个 IntentProcessTrace 中,对于同一个 Actor 作为 targettarget_index 应该完全一致。
      • target_index 代表该 intent 在 Actor 处理序列中的 intent 索引。
      • 示例: 如果 target_index 为 2,表示这是 Actor 处理的第三个 intent。
  • 扫描 Inputs:

    • 处理 Intent Cell 和 Open Transaction Inputs:
      • 扫描所有对自身 Actor Cell 进行调用的 Intent Cell 或 Open Transaction(OTX)。
      • 处理过程中涉及对内存状态的改变,例如更新资产、修改数据等。

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,无论是直接还是间接调用。
    • 按照顺序处理 invocations:
      • 对于每个 IntentProcessTrace,按照 invocations 的顺序处理整个 intent 的调用序列。
      • 找到自身作为调用方(from_script_hash 为自身)和被调方(target_script_hash 为自身)的 CrossCellInvocation
      • 所有的调用都需要与自身的处理流程匹配,确保一致性。

4. Actor 处理一个 Trace

(1)作为调用方

  • 查找匹配的 Invocation:

    • 在需要请求其他 Actor 时,找到对应的 IntentProcessTraceinvocations,按照顺序执行。
    • 找到满足以下条件的 CrossCellInvocation
      • from_script_hash 为自身的脚本哈希。
      • target_script_hash 为被调用 Actor 的脚本哈希。
      • signature 与调用的方法签名一致。
      • inputs 与调用的输入参数一致。
    • 然后读取 invocation 中的 outputs,继续处理。
    • 错误处理: 如果找不到匹配的 invocation,则报错并终止处理。
  • 支持循环调用:

    • 如果在找到匹配自身作为调用方的 invocation 之前,遇到其他 Actor 调用自己的 invocation,说明存在循环调用。
    • 可以先执行自身作为被调用方的 invocation,然后继续处理。
    • 错误处理: 如果扫描到一个 invocation,其 from_script_hash 为自身但无法匹配,则直接报错。

(2)作为被调用方

  • 检查 Invocation:

    • 检查 signature 对应的方法,使用 invocation.inputs 作为输入,执行方法逻辑。
    • 验证执行结果的 successoutputs 是否与 invocation 中的 successoutputs 一致。
    • 错误处理: 如果不一致,则报错并终止处理。
  • 处理循环调用:

    • 如果在处理过程中,没有其他 Actor 调用自身,但出现了自身调用其他 Actor 的 invocation,说明是循环调用。
    • 将这些自身作为调用方的 invocation 压入栈中,直到读取到第一个自身被调用的 invocation
    • 然后开始处理栈中的 invocation,直到处理完整个缓存栈。
    • 错误处理: 在处理过程中,如果遇到任何不匹配的情况,立即报错。

5. 处理 Intent 和更新计数器

  • 完成 intent 处理:

    • 在成功处理完一个 intent 之后,Actor 将自身的 processed_intent_count 加一。
  • 继续处理下一个 intent:

    • 根据 IntentProcessTrace 中的 intent 顺序,以及 Actor 记录的自身 target_index,决定下一个要处理的 intent。
    • 注意: 确保按照正确的顺序处理所有待处理的 intent,避免遗漏或重复处理。

6. 处理失败的 Intent

  • 检测失败状态:

    • 如果 IntentProcessTrace 中的 success 不等于 0,表示对应的 intent 整体处理失败。
  • 状态回滚:

    • 在处理完对应的 IntentProcessTrace 后,所有涉及的 Actor 应该将内存状态恢复至处理该 intent 之前的状态。
    • 实现方式: 可以采用状态快照,在处理前保存状态,处理失败时进行回滚。

7. 序列化和反序列化

  • 依据方法定义:

    • inputsoutputs 的序列化和反序列化方式,由 Actor 中 signature 对应的方法决定。
    • 可以使用 Molecule 作为统一的 ABI 格式,确保不同 Actor 之间的数据可以正确解析和处理。

8. 最终状态检查

  • 完成所有处理:

    • 当 Actor Cell Type Script 处理完所有 intent 和 message 后,内存状态达到最终状态。
  • 验证输出:

    • 检查交易 outputs 中的输出是否符合内存中的最终状态,完成处理。
    • 错误处理: 如果输出不一致,则报错并终止交易。

Intent 和 Invocation 的处理差别

需要注意的是,Actor Cell Type Script 针对 IntentInvocation 的处理方式不同。

  • 处理 Intent:

    • 当处理 Intent 时,Actor 不仅需要对输入的资产和数据进行处理,修改内存状态,还需要检查对应 intent 是否得到了满意的输出。
    • 这包括验证输出的资产和容量(Capacity)是否符合规则,以及是否满足用户的期望。
    • 理由: 直接面向用户的 Actor 必须检查其 intent 是否满足,因为没有其他脚本会执行这个检查,并且该输出直接反映在交易的输出 Cells 中。
  • 处理 Invocation:

    • 当处理 Invocation 时,Actor 只需要根据 invocation 中的 inputsoutputs 修改内存状态即可。
    • 无需检查具体结果,因为这些处理涉及的是中间状态,并未反映在交易输出中。
    • 示例: 当 Actor A 与 Actor B 通信,使用 30 USD 换取 40 CKB,Actor A 记录自身 USD 减少 30,CKB 增加 40;Actor B 反之。
    • 理由: 最终,如果在交易输出中,Actor A 和 Actor B 控制的资产和数据符合处理结果,说明中间的消息通信是正确的,资产是隐式在 Actor 间传递。

附加说明

  • 计数器的作用:

    • processed_intent_count 用于对链上处理进行排序,确保处理的顺序性和一致性。
    • intent_index 以及 target_index 都是根据 processed_intent_count 确定其处理位置的,即 intent_indextarget_index 一起组成了一个连续的序列,分别是该 Actor 作为直接被调方或者间接被调方参与的 intent。
  • 循环调用处理:

    • 通过允许在找到匹配的 invocation 之前,执行其他 Actor 对自身的调用,以支持循环调用。
    • 通过将自身作为调用方的 invocation 压栈缓存,以支持循环调用。
  • 错误处理:

    • 如果在扫描 invocations 时,发现 from_script_hash 为自身但与执行流程无法匹配的情况,立即报错,防止不一致的发生。
  • 状态一致性:

    • 在处理失败的 intent 时,必须将内存状态回滚,以保持整个系统状态的一致性。

IntentProcessTraces 的位置

  • 特殊的 WitnessLayout:

    • 设定一个特殊的 Witness 布局,用于放置 IntentProcessTraces,以方便 Script 的扫描处理。
    • 这些 traces 最好与 Intent 的处理顺序对应,以简化链上扫描。
    • 建议: 明确 WitnessLayout 的格式和解析方法,确保所有参与者遵循相同的标准。

链下 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,根据其调用顺序,拼接成一个完整的交易。
    • 这样,链上的 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 链上验证。