Tutorial: Spawn Script
- CKB dev environment: OffCKB (β₯v0.3.0-rc2)
- JavaScript SDK: CCC (β₯v0.1.0-alpha.4)
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β
- 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.
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.
- Start the devnet
offckb node
- Deploy the Scripts
Open a new terminal and cd
into 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 a 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 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
andckb-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β
- 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