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. This binary can be located within a cell. Additionally, a RISC-V binary can also be found within the witness during a syscall spawn. 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 full Rust example using Spawn syscall to call a Script from a Script 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.

Implementing Caller​

First, add a 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 using this function 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 function of caller, which involved creating pipes and use 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 string as arguments to the callee Script and hopes it returns the concat result of those two string. We also assume that the callee Script is located at the first cell from the cellDeps of the transaction.

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(())
}

One thing to be noted here is that each pipe we creating can only be either read from or written to. We create two pipes, one is for child process writing, parent process reading, one is the opposite.

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)?;

Implementing Callee​

We need to create the same thing here, a 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 logic is simpler. It received the arguments using ckb_std::env::argv() and inherited the pipe using inherited_fds.

Then it use write syscall to write the concat result to the pipe:

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

Writing Unit Tests​

The Unit Test files are located in the spawn-script/tests/src/tests.rs. We only need one test for our Scripts, which will use caller as input lock Script and verify the Script in the transaction. The callee Script Cell will be pushed into cellDeps of the transaction too.

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

Running Tests:

make test

Testing on Devnet​

After testing our Scripts, we feel more confident to test it on a more real environment.

We will use offckb to start a devnet and deploy the Scripts. Then we will test the Script by sending transaction to the devnet using offckb REPL.

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

Open a new terminal and cd into 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 a offckb REPL:

offckb repl -r

Next, we construct a transaction right 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 tx with offckb debug for 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 are using cargo build ---release mode so all the log are removed from the Script binary to reduce size.​

Congratulations!​

By following this tutorial so far, you have mastered how to write simple Spawn Scripts that brings passing 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 calling spawn Script.
  • Write unit tests to make sure the Spawn Scripts work as expected.
  • Use offckb REPL to send a testing transaction that verify the Spawn Scripts.

Additional Resources​