General Composable Intent Script(Covenant) and Asynchronous Invocation Paradigm

Background

On CKB, intentions can be expressed in two ways:

  1. Using Intent Cell to carry assets and information, and pass them to applications.
  2. Using OTX to carry assets and information, and pass them to applications.

Each method has its advantages and disadvantages:

  1. Intent Cell requires two-step transactions to pass information, while OTX operates within a single batch. Thus, Intent Cell has longer delays and must handle failure results.
  2. Intent Cell requires additional CKB, whereas OTX does not.
  3. OTX requires users to switch to a new Lock, while Intent Cell does not.
  4. In multi-party applications, if different parties use their respective aggregators, Intent Cell can be combined asynchronously, while OTX can only be combined synchronously, or OTX and Intent Cell must be combined; otherwise, multiple parties must share one aggregator to achieve synchronous combination of OTX.

This solution adopts Type Script to carry intent for the following reasons:

  1. Type can analyze the transaction that created the intent cell, whereas lock cannot, which helps in implementing some permission management tasks.
  2. Type mandates that the transaction creation only places the hash pre-image in Witness, with script args containing only the hash of intent data, while lock must place all data in args. Since args occupy on-chain state, it complicates expressing complex intents and the varying sizes of different intents create substantial difficulties for application development and SDK integration.

DEMO: ckb-intent-scripts (github.com)

This scheme also utilizes two scripts, input-type-proxy-lock and always-success. For reference, please see: ckb-ecofund/ckb-proxy-locks (github.com).

Intent_type:
    code_hash: Intent-type code_hash
    hash_type: Intent-type hash_type
    args: script_hash(20Bytes)|intent_data_hash(20Bytes)

Capacity: 32 + 1 + 40 = 73.

In args, script_hash represents the script that will process the intent, and intent_data_hash represents the hash of the corresponding intent data.

In this plan, apart from type, other cell fields are not used, the length of its data is not specified, and its lock is recommended to be an always_success_script with args length of 0. Thus, the total space for an intent_cell is 8 (capacity) + 33 (lock) + 73 (type) = 114 CKB.

The Intent-type can carry assets as intent input through the input_type_proxy_lock:

Intent-type-proxy-lock
    code_hash: input_type_proxy_lock
    hash_type: input_type_proxy_lock hash_type
    args: Intent_type_hash

When creating an intent_cell, the user also transfers the asset cell needed for the intent to the corresponding proxy_lock, so the corresponding proxy_lock will be unlocked along with the intent_cell. The size of the input_proxy_lock is 65 CKB.

If there are requirements that Input needs to place more CKB as Capacity, such as if the output’s lock_script is too long and needs to reserve Capacity, it is advisable to place all CKB in the Intent Cell.

Moreover, the following structure-defined IntentData is placed at the specified Witness position:

import blockchain;

struct ScriptAttr {
    location: byte,
    script_hash: Byte32,
}

vector ScriptAttrVec <ScriptAttr>;

table AnotherIntent {
    script_hash: Byte32,
    intent_data: Bytes,
}

union IntentTarget {
    Script,
    AnotherIntent,
}

vector IntentTargetVec <IntentTarget>;

table IntentData {
    location: byte,
    owner: ScriptAttr,
    expire_since: Uint64,
    authorizer: ScriptAttrVec,
    targets: IntentTargetVec,
    input_data: Bytes,
}

The corresponding Rust code is as follows:

pub enum ScriptLocation {
    #[default]
    InputLock,
    InputType,
    OutputLock,
}

#[derive(Default, Debug)]
pub struct ScriptAttr {
    pub loc: ScriptLocation,
    pub script_hash: [u8; 32],
}

#[derive(Default, Debug)]
pub struct AnotherIntent {
    pub script_hash: [u8; 32],
    pub intent_data: IntentData,
}

#[derive(Debug)]
pub enum IntentTarget {
    Script(packed::Script),
    AnotherIntent(AnotherIntent),
}

impl Default for IntentTarget {
    fn default() -> Self {
        Self::Script(packed::Script::default())
    }
}

#[derive(Default, Debug)]
pub struct IntentData {
    pub location: ScriptLocation,
    pub owner: ScriptAttr,
    pub expire_since: u64, // After this since condition is met, the owner can unlock the intent type.
    pub authorizers: Vec<ScriptAttr>,
    pub targets: Vec<IntentTarget>,
    pub input_data: Vec<u8>,
}

Example transaction:

Intent creation tx:
    inputs:
        asset_x cell
        asset_y cell
        capacity cell
    output:
        intent_cell with intent_type
        asset_x cell with proxy_lock
        asset_y cell with proxy_lock
    witnesses:
        intent_data

Checks when intent_cell is in output:

  • The witness.output_type at the corresponding position has input_data, whose hash matches the intent_data_hash specified in args.
  • Parses input_data, verifies all targets, ensuring all AnotherIntent’s intent_data can be decoded into IntentData, and performs recursive parsing.
  • Ensures all authorizers in the intent_data exist in the transaction, whether in inputs or outputs, through lock or type.

Checks when intent_cell is in input:

  • The witness.input_type at the corresponding position has input_data, whose hash matches the intent_data_hash specified in args.
    • There exists a script in the transaction, whose script_hash satisfies the script_hash specified in type args, and its location aligns with the location specified in intent_data.
    • After meeting the expire_since condition, the owner can unlock the intent_cell and retrieve all assets.

When the called Script verifies whether the intent is satisfied, it can use the following:

  1. Authorizer: All authorizers have already authorized this intent, so when a authorizer exists, it can be used for permission management.
  2. Target: Script can freely use the target; generally, if the Target is a Script, then the intent’s output will transfer to the corresponding lock; if the target is Another Intent, then the transaction’s output will be another Intent_cell controlled by proxy_lock.
  3. Input_data: Script can analyze and process its own business.
  4. Asset_cell: Script can scan the asset Cell accompanying the intent and process it accordingly.

The above checks are just suggestions for Script to verify if the intent is satisfied. For instance, when an another intent target cannot fully determine parameters during intent construction, Script can impose some limitations in the input_data and dynamically output to a constrained new Intent cell during aggregator transaction construction.

Through this model, one can achieve on CKB:

  1. A general function calling simulation mode.
  2. By outputting to another Intent, multi-layered function calls are realized. However, the whole process is entirely asynchronous, and even if subsequent calls fail, the previous call will not roll back. Through the Intent chain, any asynchronous calls can be implemented.

Here’s the translation of the technical document:

Intent Lock
In some cases, using Intent Lock can save space and simplify usage, especially when the intent doesn’t involve authentication and only carries one Cell.

Intent_lock:
    code_hash: Intent-type code_hash
    hash_type: Intent-type hash_type
    args: receiver_script_hash(32Bytes)|receiver_location(1byte)|owner_script_hash(32)|owner_location(1byte)|expire_since(8byte)|input_data

The checks for intent_lock are as follows:

  • There exists a script in the transaction whose script_hash matches the receiver_script_hash specified in the args, and its location complies with the location requirement.
  • After the expire_since condition is met, the owner can unlock the intent_cell and retrieve all assets.

For the first cell carried by the intent, its lock is intent-lock, while the locks for other cells are input-lock-proxy-lock, which determines whether to unlock by checking if the intent-lock has been unlocked.

Intent-lock-proxy-lock
    code_hash: input_lock_proxy_lock
    hash_type: input_lock_proxy_lock hash_type
    args: Intent_lock_hash
6 Likes

通用可组合的 Intent Script 与 异步调用范式

背景

在 CKB 上,可以使用两种方式表达意图:

  1. 使用 Intent Cell 携带资产和信息,并传递给应用。
  2. 使用 OTX 携带资产和信息,并传递给应用

这两种方式各有优劣:

  1. Intent Cell 需要两步交易传递信息,而 OTX 只在一个batch内,则 Intent Cell 方式存在更长的延迟,并且要处理失败返还情况。
  2. Intent Cell 需要额外的 CKB,而 OTX 不需要。
  3. OTX 需要用户转移至新的 Lock 使用,而 Intent Cell 不需要。
  4. 涉及多方应用时,如果不同应用方使用各自的 Aggregator,intent cell 可以异步组合,而 OTX 只能同步组合,或者说 OTX 和 Intent Cell 必须结合起来,否则多个应用方必须共用一个Aggregator 才能实现 OTX 的同步组合。

本方案采用 type 承载 intent,理由如下:

  1. type 可以分析创建该 intent cell 的交易,而lock不能,这将有助于实现一些权限管理的工作。
  2. type 可以强制要求创建交易在Witness里将哈希原像,script args里只放置意图数据的哈希,而lock只能将所有数据放置在args里,由于args需要占用链上状态,使得复杂的intent难以表达,并且不同 intent 占据的空间大小不一,这对于应用开发以及sdk对接都会产生很大的麻烦。

DEMO: ckb-intent-scripts (github.com)

本方案还使用了两个 Script,input-type-proxy-lock 和 always-success,请参见: ckb-ecofund/ckb-proxy-locks (github.com)

Intent_type:
	code_hash: Intent-type code_hash
	hash_type: Intent-type hash_type
	args: scipt_hash(20Bytes)|intent_data_hash(20Bytes)

capacity: 32 + 1 + 40 = 73。

args 中的 script_hash 表示将处理意图的script,intent_data_hash 表示对应意图数据的哈希。

在本方案中,除了 type 之外,其他 cell 的字段均不做使用,其data的长度不做规定,其 lock 推荐为 args 长度为 0 的 always_success_script,根据这个设计,则 intent_cell 的总空间为 8(capacity) + 33(lock) + 73(type) = 114 CKB。

该 Intent-type 可以通过 input_type_proxy_lock 携带资产,作为意图的输入:

Intent-type-proxy-lock
	code_hash: input_type_proxy_lock
	hash_type: input_type_proxy_lock hash_type
	args: Intent_type_hash

用户在创建 intent_cell 时,将 intent 需要用到的资产 cell 也转移至对应的 proxy_lock,则对应的 proxy_lock 将随着 intent_cell 一起解锁,input_proxy_lock 的大小为 65 CKB。

如果处于某些要求,Input需要放置更多的 CKB 作为 Capacity,如输出的 lock_script 过长,需要预留 Capacity,建议将CKB都放置在Intent Cell里。

并且在给定的 Witeness 位置上,放置以下结构所定义的 IntentData :

import blockchain;

struct ScriptAttr {
    location: byte,
    script_hash: Byte32,
}

vector ScriptAttrVec <ScriptAttr>; 

table AnotherIntent {
    script_hash: Byte32,
    intent_data: Bytes,
}

union IntentTarget {
	Script,
	AnotherIntent,
}

vector IntentTargetVec <IntentTarget>;

table IntentData {
    type_id: Byte32,
    receiver_location: byte,
    owner: ScriptAttr,
    expire_since: Uint64,
    authorizers: ScriptAttrVec,
    targets: BytesVec,
    input_data: Bytes,
}

其对应的Rust代码如下:

#[derive(Default, Debug)]
pub enum ScriptLocation {
    #[default]
    InputLock,
    InputType,
    OutputLock,
}

#[derive(Default, Debug)]
pub struct ScriptAttr {
    pub loc: ScriptLocation,
    pub script_hash: [u8; 32],
}

#[derive(Default, Debug)]
pub struct AnotherIntent {
    pub script_hash: [u8; 32],
    pub intent_data: IntentData,
}

#[derive(Debug)]
pub enum IntentTarget {
    Script(packed::Script),
    AnotherIntent(AnotherIntent),
}

impl Default for IntentTarget {
    fn default() -> Self {
        Self::Script(packed::Script::default())
    }
}

#[derive(Default, Debug)]
pub struct IntentData {
    pub location: ScriptLocation,
    pub owner: ScriptAttr,
    pub expire_since: u64, // 在满足这个since条件后,owner可以解锁intent type
    pub authorizers: Vec<ScriptAttr>,
    pub targets: Vec<IntentTarget>,
    pub input_data: Vec<u8>,
}

交易示例如下:

Intent creation tx:
	inputs:
		asset_x cell
		asset_y cell
		capacity cell
	output:
		intent_cell with intent_type
		asset_x cell with proxy_lock
		asset_y cell with proxy_lock
	witnesses:
		intent_data

intent_cell 在output中时,intent_type 会做如下检查:

  • 对应位置的 witness.output_type 存在 input_data,其哈希与args中所规定的intent_data_hash匹配。
  • 解析 input_data,验证所有的Target,确保所有AnotherIntent里的 intent_data 都可以被解析成 IntentData,执行递归解析。
  • 确保intent_data中所有的 authorizers 都在交易中存在,它可以是 inputs 中的lock,type,或者output中的type。

intent_cell 在 input 中时,验证如下:

  • 对应位置的 witness.input_type 存在 input_data,其哈希与args中所规定的intent_data_hash匹配。
  • 交易中存在一个 script,其 script_hash 满足 type 的args中所规定的 script_hash,并且其所处位置符合 intent_data 中 location 的规定。
  • 满足 expire_since 的条件后,owner可以解锁 intent_cell,并取回所有资产。

在被调用的 Script 验证intent是否满足时,它可以使用的项包括:

  1. authorizer:所有authorizer必然已经授权了这个intent,所以当authorizer存在时,可以用于权限管理。
  2. target:Script 可自由使用target,一般来说,假设Target是Script,则intent的输出将转移至对应的lock,如果target是AnotherIntent,则交易的输出将是另一个 Intent_cell 以及由 proxy_lock 控制的资产cell。
  3. input_data:script凭借自己的业务解析并处理。
  4. asset_cell:script 可以扫描 intent 附带的资产 Cell,并进行相应处理。

上述检查只是对于 Script 检查 intent 是否满足的建议,比如说当一个another intent target 在intent 构造时不能完全确定参数时,Script 可以在 input_data 中进行一些限制,并且在 Aggregator 拼交易时根据情况动态地输出到一个受约束的新 Intent cell 里去。

通过这个模式,可以在CKB上获得:

  1. 一种通用的函数调用模拟方式。
  2. 通过输出到另一个Intent,实现了函数的多层调用,但缺点是整个过程是完全异步的,并且哪怕后面的调用失败,前一个调用也不会回滚,通过Intent链条,可以实现任意的异步调用。

Intenk Lock

在某些时候,使用Intent Lock可以节省一定的空间,并简化使用,尤其是 intent 不需要涉及鉴权,并且只携带一个Cell时。

Intent_lock:
	code_hash: Intent-type code_hash
	hash_type: Intent-type hash_type
	args: receiver_scipt_hash(32Bytes)|receiver_location(1byte)|owner_script_hash(32)|owner_location(1byte)|expire_since(8byte)|input_data

intent_lock的检查如下:

  • 交易中存在一个 script,其 script_hash 满足 args中所规定的 receiver_script_hash,并且其所处位置符合 location 的规定。
  • 满足 expire_since 的条件后,owner可以解锁 intent_cell,并取回所有资产。

对应intent第一个携带的cell,其lock为intent-lock,而其他的cell的lock则为input-lock-proxy-lock,其通过检查intent-lock有无解锁决定是否解锁。

Intent-lock-proxy-lock
	code_hash: input_lock_proxy_lock
	hash_type: input_lock_proxy_lock hash_type
	args: Intent_lock_hash
1 Like

looks good - this essentially decouples a UTXO transaction into payments => predicate (typescript) checking => payments, and feels like a good abstraction for developers. if aggregators also build around this pattern, they could batch the entire async process to facilitate the synchronous experience.

This is also getting similar to Fuel’s Predicate based programming model, although for us it’s an opt-in.

2 Likes

In translated content:

但缺点是整个过程是完全异步的

But original does not seems to describe asynchronous as a flaw:

However, the whole process is entirely asynchronous, and even if subsequent calls fail …

Asynchronous is better matching CKB’s cell model imo.

One question is: can we compose multiple different Intent Script in one single transaction?

1 Like

yes, we can design a pattern to achive Cross-script synchronous invocation starting from intent-cell, but this requires further design and onchain script/ offchain Aggregator collaboration.
I’ll write a new article to discuss this issue when I have time.

1 Like

if those multiple different Intent Scripts are independent, of course we can.

Another case is that a transaction contains multiple intent cells calling the same script, which is also easy to handle. The script only needs to scan inputs and process all intent_cells one by one in order until they are processed.
[intent_cell_1, intent_cell_2.....intent_cell_n] -> Script -> [output_1......output_n]

the difficult thing is handling issues like intent_cell -> Script-A -> Script-B -> Script-C -> final_output in a single tx.

1 Like