Skip to main content

Tutorial: Spawn Script

The design of the syscall spawn function draws inspiration from Unix and Linux, hence they share the same terminologies: process, pipe, and file descriptor. The spawn mechanism is used in CKB-VM to create new processes, which can then execute a different program or command independently of the parent process.

In the context of CKB-VM, a process represents the active execution of a RISC-V binary, which can be located within a Cell. Additionally, a RISC-V binary can also be found within the witness during a Spawn syscall. A pipe is established by associating two file descriptors, each linked to one of its ends. These file descriptors can't be duplicated and are exclusively owned by the process. Furthermore, the file descriptors can only be either read from or written to; they can't be both read from and written to simultaneously.

In this tutorial, you will learn how to write a complete Rust example using Spawn syscall to call a Script from another on CKB. The full code of this tutorial can be found at Github.

Initialize a Script Project​

offckb create --script spawn-script

Create Two New Scripts​

Let’s create a new Script called caller inside the project.

cd caller-script
make generate

And create another new Script called callee inside the project.

make generate

Our project is successfully setup now.

Implement Caller​

First, add an error handler module called Error in error.rs:

use ckb_std::error::SysError;

#[cfg(test)]
extern crate alloc;

#[repr(i8)]
pub enum Error {
IndexOutOfBound = 1,
ItemMissing,
LengthNotEnough,
Encoding,
WaitFailure,
InvalidFD,
OtherEndClose,
MaxVmSpawned,
MaxFdCreated,
// Add customized errors here...
}

impl From<SysError> for Error {
fn from(err: SysError) -> Self {
match err {
SysError::IndexOutOfBound => Self::IndexOutOfBound,
SysError::ItemMissing => Self::ItemMissing,
SysError::LengthNotEnough(_) => Self::LengthNotEnough,
SysError::Encoding => Self::Encoding,
SysError::WaitFailure => Self::WaitFailure,
SysError::InvalidFd => Self::InvalidFD,
SysError::OtherEndClosed => Self::OtherEndClose,
SysError::MaxVmsSpawned => Self::MaxVmSpawned,
SysError::MaxFdsCreated => Self::MaxFdCreated,
SysError::Unknown(err_code) => panic!("unexpected sys error {}", err_code),
}
}
}

Let's include the error module and necessary libs in the main.rs file:

mod error;
use ckb_std::syscalls::SpawnArgs;
use core::ffi::CStr;

Next, we will wrap the logic in a caller function and use it in the program_entry:

pub fn program_entry() -> i8 {
ckb_std::debug!("Enter caller contract!");

match caller() {
Ok(_) => 0,
Err(err) => err as i8,
}
}

fn caller() -> Result<(), error::Error> {}

Now, let's implement the caller function, which creates pipes and uses Spawn syscall to call another Script in a standalone process.

The pipe is used as a one-direction data flow that carries data from one Script to another Script. We will pass two strings as arguments to the callee Script, expecting it to return their concatenated result. We also assume that the callee Script is located in the first Cell of the transaction's Cell Deps.

fn caller() -> Result<(), error::Error> {
let (r1, w1) = ckb_std::syscalls::pipe()?;
let (r2, w2) = ckb_std::syscalls::pipe()?;
let to_parent_fds: [u64; 2] = [r1, w2];
let to_child_fds: [u64; 3] = [r2, w1, 0]; // must ends with 0

let mut pid: u64 = 0;
let place = 0; // 0 means read from cell data
let bounds = 0; // 0 means read to end
let argc: u64 = 2;
let argv = [
CStr::from_bytes_with_nul(b"hello\0").unwrap().as_ptr(),
CStr::from_bytes_with_nul(b"world\0").unwrap().as_ptr(),
];
let mut spgs: SpawnArgs = SpawnArgs {
argc,
argv: argv.as_ptr(),
process_id: &mut pid as *mut u64,
inherited_fds: to_child_fds.as_ptr(),
};
ckb_std::syscalls::spawn(
0,
ckb_std::ckb_constants::Source::CellDep,
place,
bounds,
&mut spgs,
)?;

let mut buf = [0; 256];
let len = ckb_std::syscalls::read(to_parent_fds[0], &mut buf)?;
assert_eq!(len, 10);
buf[len] = 0;
assert_eq!(
CStr::from_bytes_until_nul(&buf).unwrap().to_str().unwrap(),
"helloworld"
);
Ok(())
}

Note that each pipe can only be used for either reading or writing. To facilitate communication, we create two pipes: one for the child process to write and the parent process to read; the other does the reverse.

let (r1, w1) = ckb_std::syscalls::pipe()?;
let (r2, w2) = ckb_std::syscalls::pipe()?;
let to_parent_fds: [u64; 2] = [r1, w2];
let to_child_fds: [u64; 3] = [r2, w1, 0]; // must ends with 0


// ...

let len = ckb_std::syscalls::read(to_parent_fds[0], &mut buf)?;

Implement Callee​

We need to create the same components here: an error.rs file and a callee function in the main.rs:

use ckb_std::error::SysError;

#[cfg(test)]
extern crate alloc;

#[repr(i8)]
pub enum Error {
IndexOutOfBound = 1,
ItemMissing,
LengthNotEnough,
Encoding,
WaitFailure,
InvalidFD,
OtherEndClose,
MaxVmSpawned,
MaxFdCreated,
// Add customized errors here...
}

impl From<SysError> for Error {
fn from(err: SysError) -> Self {
match err {
SysError::IndexOutOfBound => Self::IndexOutOfBound,
SysError::ItemMissing => Self::ItemMissing,
SysError::LengthNotEnough(_) => Self::LengthNotEnough,
SysError::Encoding => Self::Encoding,
SysError::WaitFailure => Self::WaitFailure,
SysError::InvalidFd => Self::InvalidFD,
SysError::OtherEndClosed => Self::OtherEndClose,
SysError::MaxVmsSpawned => Self::MaxVmSpawned,
SysError::MaxFdsCreated => Self::MaxFdCreated,
SysError::Unknown(err_code) => panic!("unexpected sys error {}", err_code),
}
}
}
mod error;
use alloc::vec;

pub fn program_entry() -> i8 {
ckb_std::debug!("Enter callee contract!");

match callee() {
Ok(_) => 0,
Err(err) => err as i8,
}
}

pub fn callee() -> Result<(), error::Error> {
let argv = ckb_std::env::argv();
let mut to_parent_fds: [u64; 2] = [0; 2];
ckb_std::syscalls::inherited_fds(&mut to_parent_fds);
let mut out = vec![];
for arg in argv {
out.extend_from_slice(arg.to_bytes());
}
let len = ckb_std::syscalls::write(to_parent_fds[1], &out)?;
assert_eq!(len, 10);
Ok(())
}

The callee function has a simpler logic. It receives arguments using ckb_std::env::argv() and inherits the pipe using inherited_fds.

Then it uses the write syscall to write the concatenated result to the pipe:

let len = ckb_std::syscalls::write(to_parent_fds[1], &out)?;

Write Unit Tests​

The Unit Test files are located in the spawn-script/tests/src/tests.rs. We'll create one test for our Scripts that uses caller as the input Lock Script and verifies it in the transaction. The callee Script Cell will also be pushed into the Cell Deps of the transaction.

use crate::Loader;
use ckb_testtool::ckb_types::{
bytes::Bytes,
core::TransactionBuilder,
packed::*,
prelude::*,
};
use ckb_testtool::context::Context;

// Include your tests here
// See https://github.com/xxuejie/ckb-native-build-sample/blob/main/tests/src/tests.rs for more examples

// generated unit test for contract caller
#[test]
fn test_spawn() {
// deploy contract
let mut context = Context::default();
let caller_contract_bin: Bytes = Loader::default().load_binary("caller");
let caller_out_point = context.deploy_cell(caller_contract_bin);
let callee_contract_bin: Bytes = Loader::default().load_binary("callee");
let callee_out_point = context.deploy_cell(callee_contract_bin);

// prepare scripts
let lock_script = context
.build_script(&caller_out_point, Bytes::from(vec![42]))
.expect("script");

// prepare cells
let input_out_point = context.create_cell(
CellOutput::new_builder()
.capacity(1000u64.pack())
.lock(lock_script.clone())
.build(),
Bytes::new(),
);
let input = CellInput::new_builder()
.previous_output(input_out_point)
.build();
let outputs = vec![
CellOutput::new_builder()
.capacity(500u64.pack())
.lock(lock_script.clone())
.build(),
CellOutput::new_builder()
.capacity(500u64.pack())
.lock(lock_script)
.build(),
];

let outputs_data = vec![Bytes::new(); 2];

// prepare cell deps
let callee_dep = CellDep::new_builder()
.out_point(callee_out_point)
.build();
let caller_dep = CellDep::new_builder()
.out_point(caller_out_point)
.build();
let cell_deps: Vec<CellDep> = vec![callee_dep, caller_dep];

// build transaction
let tx = TransactionBuilder::default()
.input(input)
.outputs(outputs)
.outputs_data(outputs_data.pack())
.cell_deps(cell_deps)
.build();
let tx = context.complete_tx(tx);

// run
let cycles = context
.verify_tx(&tx, 10_000_000)
.expect("pass verification");
println!("consume cycles: {}", cycles);
}

First, build the Scripts:

make build

Then, run the tests:

make test

Test on Devnet​

After testing our Scripts, we can be more confident to test them in a more realistic environment.

We will use offckb to start a Devnet and deploy the Scripts, then test the Scripts by sending a transaction to the Devnet using offckb REPL.

  1. Start the Devnet
offckb node
  1. Deploy the Scripts

Open a new terminal, navigate to the root of the spawn-script project, then run:

offckb deploy --target build/release/caller
offckb deploy --target build/release/callee
  1. Send a Test Transaction

Open a new terminal and start an offckb REPL:

offckb repl -r

Next, we'll construct a transaction in the offckb REPL:

OffCKB > let amountInCKB = ccc.fixedPointFrom(63);
OffCKB > let caller = myScripts['caller'];
OffCKB > let lockScript = new ccc.Script(caller.codeHash, caller.hashType, "0x00");
OffCKB > let tx = ccc.Transaction.from({
... outputs: [
... {
... capacity: ccc.fixedPointFrom(amountInCKB),
... lock: lockScript,
... },
... ],
... cellDeps: [
... ...myScripts["callee"].cellDeps.map(c => c.cellDep),
... ...myScripts['caller'].cellDeps.map(c => c.cellDep),
... ]}
... );
OffCKB > let signer = new ccc.SignerCkbPrivateKey(client, accounts[0].privkey);
OffCKB > await tx.completeInputsByCapacity(signer);
1
OffCKB > await tx.completeFeeBy(signer, 1000);
[ 0, true ]
OffCKB > await signer.sendTransaction(tx)
'0x252305141e6b7db81f7da94b098493a36b756fe9d5d4436c9d7c966882bc0b38'

We can re-run the transaction with offckb debug to test our Scripts:

offckb debug --tx-hash 0x252305141e6b7db81f7da94b098493a36b756fe9d5d4436c9d7c966882bc0b38
Dump transaction successfully

******************************
****** Input[0].Lock ******

Run result: 0
All cycles: 1646754(1.6M)
note

The deployed Scripts use cargo build --release mode that removes all the logs from the Script binary to reduce the size.

Congratulations!​

By following this tutorial so far, you have mastered how to write simple Spawn Scripts that pass data between them. Here's a quick recap:

  • Use offckb and ckb-script-templates to init a Script project
  • Use ckb_std to leverage CKB syscalls to create pipes and call Spawn Script.
  • Write unit tests to make sure the Spawn Scripts work as expected.
  • Use offckb REPL to send a testing transaction that verifies Spawn Scripts.

Additional Resources​