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β
- Command
- Response
offckb create --script spawn-script
β οΈ Favorite `gh:cryptape/ckb-script-templates` not found in config, using it as a git repository: https://github.com/cryptape/ckb-script-templates.git
π€· Project Name: spawn-script
π§ Destination: /tmp/spawn-script ...
π§ project-name: spawn-script ...
π§ Generating template ...
π§ Moving generated files into: `/tmp/spawn-script`...
π§ Initializing a fresh Git repository
β¨ Done! New project created /tmp/spawn-script
Create Two New Scriptsβ
Letβs create a new Script called caller
inside the project.
- Command
- Response
cd caller-script
make generate
π€· Project Name: caller
π§ Destination: /tmp/spawn-script/contracts/caller ...
π§ project-name: caller ...
π§ Generating template ...
π§ Moving generated files into: `/tmp/caller-script/contracts/caller`...
π§ Initializing a fresh Git repository
β¨ Done! New project created /tmp/caller-script/contracts/caller
And create another new Script called callee
inside the project.
- Command
- Response
make generate
π€· Project Name: callee
π§ Destination: /tmp/spawn-script/contracts/callee ...
π§ project-name: callee ...
π§ Generating template ...
π§ Moving generated files into: `/tmp/spawn-script/contracts/callee`...
π§ Initializing a fresh Git repository
β¨ Done! New project created /tmp/spawn-script/contracts/callee
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.
- Start the Devnet
offckb node
- Deploy the Scripts
Open a new terminal, navigate to the root of the spawn-script
project, then run:
- Command
- Response
offckb deploy --target build/release/caller
contract caller deployed, tx hash: 0x74bed00091f062e46225662fc90e460a4cc975478117eaa8570d454bf8dc58e9
wait for tx confirmed on-chain...
tx committed.
caller deployment.toml file /Users/retric/Library/Application Support/offckb-nodejs/devnet/contracts/caller/deployment.toml generated successfully.
caller migration json file /Users/retric/Library/Application Support/offckb-nodejs/devnet/contracts/caller/migrations/2024-11-23-133222.json generated successfully.
done.
- Command
- Response
offckb deploy --target build/release/callee
contract callee deployed, tx hash: 0xdb91398beafb3c41b3e6f4c4a078a08aa1f4245b0d964b9d51913e8514f20b72
wait for tx confirmed on-chain...
tx committed.
callee deployment.toml file /Users/retric/Library/Application Support/offckb-nodejs/devnet/contracts/callee/deployment.toml generated successfully.
callee migration json file /Users/retric/Library/Application Support/offckb-nodejs/devnet/contracts/callee/migrations/2024-11-23-133257.json generated successfully.
done.
- Send a Test Transaction
Open a new terminal and start an offckb REPL:
- Command
- Response
offckb repl -r
Welcome to OffCKB REPL!
[[ Default Network: devnet, enableProxyRPC: true, CCC SDK: 0.0.16-alpha.3 ]]
Type 'help()' to learn how to use.
OffCKB >
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)
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
andckb-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β
- Full source code of this tutorial: spawn-script
- CKB syscalls specs: RFC-0009
- Script templates: ckb-script-templates
- CKB transaction structure: RFC-0022-transaction-structure
- CKB data structure basics: RFC-0019-data-structure