Tutorial: Run JavaScript Code on CKB
Tutorial Overview
- Rust and riscv64 target:
rustup target add riscv64imac-unknown-none-elf
The High-Level Ideaβ
As we have learned before, you can use any programming language to write a Script (Smart contract) for CKB. But does it really work in reality? This tutorial will show a full example of using JavaScript to write Scripts and execute them in the CKB-VM.
The process is as follows: first we port a JavaScript engine as a base Script to CKB. Then, we write the business logic in JavaScript and execute this JS-powered Script within the base Script on top of CKB-VM.
It sounds like a of work. But thanks to the CKB VM team, we already have a fully runnable JavaScript engine called ckb-js-vm. It is ported from quick.js so that it is compatible with running on CKB-VM. We just need to take the ckb-js-vm and deploy it on-chain before we can run our JS Script.
Below is a step-by-step guide, and you can also clone the full code example from the Github repo.
Get ckb-js-vm Binaryβ
The ckb-js-vm
is a binary that can be used both in the CLI and in the on-chain CKB-VM. Let's first build the
binary and give it a try to see if it works as expected.
You will need clang 16+
to build the ckb-js-vm
binary:
git clone https://github.com/nervosnetwork/ckb-js-vm
cd ckb-js-vm
git submodule update --init
make all
Now, the binary is in the build/
folder. Without writing any codes, we can use the
CKB-Debugger(another CLI tool that
enables off-chain Script development, as the name suggests) to run the ckb-js-vm
binary for a
quick test.
Install CKB-Debuggerβ
cargo install --git https://github.com/nervosnetwork/ckb-standalone-debugger ckb-debugger
Quick Test with CKB-Debuggerβ
Now let's run the ckb-js-vm
with some JS test codes.
Make sure you are in the root of the ckb-vm-js
project folder:
- Command
- Response
ckb-debugger --read-file tests/examples/hello.js --bin build/ckb-js-vm -- -r
Run from file, local access enabled. For testing only.
hello, world
Run result: 0
Total cycles consumed: 30081070(2.9m)
Transfer cycles: 125121(122.2k), running cycles: 2955949(2.8m)
With the -r
option, ckb-js-vm
will read a local JS file via CKB-Debugger. This function is
intended for testing purposes and does not function in a production environment. However, we can see the
running output, which includes a hello, world
message. The run result is 0, indicating that the hello.js
Script executes successfully.
Also, you can see how many cycles
(the overhead required to execute a Script) are needed to run the JS Script in the output as well.
Integrate ckb-js-vmβ
ckb-js-vm
offers different ways to be integrated into your own Scripts. In the next step, we will set
up a project and writing codes to integrate ckb-js-vm
with JavaScript code to gain a deeper
understanding.
The first step is to create a new Script project. We use ckb-script-templates for this purpose. You will need the following dependencies:
Init a Script Projectβ
Now let's run the command to generate a new Script project called my-first-script-workspace
:
- Command
- Response
alias create-ckb-scripts="cargo generate gh:cryptape/ckb-script-templates workspace"
create-ckb-scripts
β οΈ 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: my-first-script-workspace
π§ Destination: /tmp/my-first-script-workspace ...
π§ project-name: my-first-script-workspace ...
π§ Generating template ...
π§ Moving generated files into: `/tmp/my-first-script-workspace`...
π§ Initializing a fresh Git repository
β¨ Done! New project created /tmp/my-first-script-workspace
Create a New Scriptβ
Letβs create a new Script called run-js
.
- Command
- Response
cd my-first-script-workspace
make generate
π€· Project Name: run-js
π§ Destination: /tmp/my-first-script-workspace/contracts/run-js ...
π§ project-name: carrot ...
π§ Generating template ...
π§ Moving generated files into: `/tmp/my-first-script-workspace/contracts/run-js`...
π§ Initializing a fresh Git repository
β¨ Done! New project created /tmp/my-first-script-workspace/contracts/run-js
Our project relies on ckb-js-vm
, so we need to include it in the project. Create a new folder named
deps
in the root of our Script workspace:
cd my-first-script-workspace
mkdir deps
Copy the ckb-js-vm
binary we built before into the deps
folder. When you're done, it should look like this:
--build
--contracts
--deps
--ckb-js-vm
...
Everything looks good now!
Integrate via Scriptβ
The simplest way to run JavaScript code using ckb-js-vm
is via a Script. A ckb-js-vm
Script has the
following structure:
code_hash: <code_hash to ckb-js-vm cell>
hash_type: <hash_type>
args: <ckb-js-vm args, 2 bytes> <code_hash to JavaScript code cell, 32 bytes> <hash_type to
javascript code cell, 1 byte> <javascript code args, variable length>
2 bytes ckb-js-vm args are reserved for further use
Now let's get our hands dirty to integrate ckb-js-vm
in this way.
Write a simple hello.js
Scriptβ
cd my-first-script-workspace
mkdir js/build
touch js/hello.js
Fill the hello.js
with the following code:
console.log("hello, ckb-js-script!");
Compile the hello.js
into binary with CKB-Debuggerβ
ckb-debugger --read-file js/hello.js --bin deps/ckb-js-vm -- -c | awk '/Run result: 0/{exit} {print}' | xxd -r -p > js/build/hello.bc
Write tests for the hello.js
Scriptβ
Now let's assemble all the Scripts and run them in a single CKB transaction. We will use the built-in test module
from ckb-script-templates
, which allows us to test without actually running a blockchain.
use super::*;
use ckb_testtool::{
builtin::ALWAYS_SUCCESS,
ckb_types::{bytes::Bytes, core::TransactionBuilder, packed::*, prelude::*},
context::Context,
};
const MAX_CYCLES: u64 = 10_000_000;
#[test]
fn hello_script() {
// deploy contract
let mut context = Context::default();
let loader = Loader::default();
let js_vm_bin = loader.load_binary("../../deps/ckb-js-vm");
let js_vm_out_point = context.deploy_cell(js_vm_bin);
let js_vm_cell_dep = CellDep::new_builder()
.out_point(js_vm_out_point.clone())
.build();
let js_script_bin = loader.load_binary("../../js/build/hello.bc");
let js_script_out_point = context.deploy_cell(js_script_bin.clone());
let js_script_cell_dep = CellDep::new_builder()
.out_point(js_script_out_point.clone())
.build();
// prepare scripts
let always_success_out_point = context.deploy_cell(ALWAYS_SUCCESS.clone());
let lock_script = context
.build_script(&always_success_out_point.clone(), Default::default())
.expect("script");
let lock_script_dep = CellDep::new_builder()
.out_point(always_success_out_point)
.build();
// prepare cell deps
let cell_deps: Vec<CellDep> = vec![lock_script_dep, js_vm_cell_dep, js_script_cell_dep];
// 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.clone())
.build();
// args: <ckb-js-vm args, 2 bytes> <code_hash to JavaScript code cell, 32 bytes> <hash_type to JavaScript code cell, 1 byte> <JavaScript code args, variable length>
let mut type_script_args: [u8; 35] = [0u8; 35];
let reserved = [0u8; 2];
let (js_cell, _) = context.get_cell(&js_script_out_point.clone()).unwrap();
let js_type_script = js_cell.type_().to_opt().unwrap();
let code_hash = js_type_script.calc_script_hash();
let hash_type = js_type_script.hash_type();
type_script_args[..2].copy_from_slice(&reserved);
type_script_args[2..34].copy_from_slice(code_hash.as_slice());
type_script_args[34..35].copy_from_slice(&hash_type.as_slice());
let type_script = context
.build_script(&js_vm_out_point, type_script_args.to_vec().into())
.expect("script");
let outputs = vec![
CellOutput::new_builder()
.capacity(500u64.pack())
.lock(lock_script.clone())
.type_(Some(type_script.clone()).pack())
.build(),
CellOutput::new_builder()
.capacity(500u64.pack())
.lock(lock_script)
.build(),
];
// prepare output cell data
let outputs_data = vec![Bytes::new(), Bytes::new()];
// build transaction
let tx = TransactionBuilder::default()
.cell_deps(cell_deps)
.input(input)
.outputs(outputs)
.outputs_data(outputs_data.pack())
.build();
let tx = tx.as_advanced_builder().build();
// run
let cycles = context
.verify_tx(&tx, MAX_CYCLES)
.expect("pass verification");
println!("consume cycles: {}", cycles);
}
Let's break down the code provided:
First, We deploy the ckb-js-vm
, hello.bc
and ALWAYS_SUCCESS
binaries to the blockchain, resulting in
3 Scripts in Live Cells. The ALWAYS_SUCCESS
is used solely to simplify the
Lock Script in our test flow.
Then, we build an output Cell that carries a special Type Script to execute the hello.js
codes.
The code_hash
and hash_type
in the Type Script reference the ckb-js-vm
Script Cell. It
is automatically done by this line of code:
let type_script = context
.build_script(&js_vm_out_point, type_script_args.to_vec().into())
.expect("script");
The key here is the args of the Type Script. We locate the Cell that carries our hello.js
codes and
insert the reference informationβwhich includes code_hash
and hash_type
βof that Cell into the args,
following the args structure of ckb-js-vm
.
// args: <ckb-js-vm args, 2 bytes> <code_hash to JavaScript code cell, 32 bytes> <hash_type to JavaScript code cell, 1 byte> <JavaScript code args, variable length>
let mut type_script_args: [u8; 35] = [0u8; 35];
let reserved = [0u8; 2];
let (js_cell, _) = context.get_cell(&js_script_out_point.clone()).unwrap();
let js_type_script = js_cell.type_().to_opt().unwrap();
let code_hash = js_type_script.calc_script_hash();
let hash_type = js_type_script.hash_type();
type_script_args[..2].copy_from_slice(&reserved);
type_script_args[2..34].copy_from_slice(code_hash.as_slice());
type_script_args[34..35].copy_from_slice(&hash_type.as_slice());
Finally, don't forget to add all the Live Cells containing the related Scripts in the cellDeps
in the transaction:
// prepare cell deps
let cell_deps: Vec<CellDep> = vec![lock_script_dep, js_vm_cell_dep, js_script_cell_dep];
// build transaction
let tx = TransactionBuilder::default()
.cell_deps(cell_deps)
.input(input)
.outputs(outputs)
.outputs_data(outputs_data.pack())
.build();
let tx = tx.as_advanced_builder().build();
// run
let cycles = context
.verify_tx(&tx, MAX_CYCLES)
.expect("pass verification");
println!("consume cycles: {}", cycles);
Run the Test to See If It Passesβ
make build
make test
By default, the test output does not display the executing logs of the Scripts. To view them, you can use the following alternative command:
- Command
- Response
cargo test -- --nocapture
running 1 test
[contract debug] hello, ckb-js-script!
consume cycles: 3070458
test tests::hello_script ... ok
The logs show hello, ckb-js-script!
, indicating our JavaScript code executed successfully.
Write a fib.js
Scriptβ
We can try a different JavaScript example. Let's write a fib.js
in the js
folder:
console.log("testing fib");
function fib(n) {
if (n <= 0) return 0;
else if (n == 1) return 1;
else return fib(n - 1) + fib(n - 2);
}
var value = fib(10);
console.assert(value == 55, "fib(10) = 55");
Compile the fib.js
into Binary with CKB-Debuggerβ
ckb-debugger --read-file js/fib.js --bin deps/ckb-js-vm -- -c | awk '/Run result: 0/{exit} {print}' | xxd -r -p > js/build/fib.bc
Add a New Test for The fib.js
Scriptβ
#[test]
fn fib_script() {
// deploy contract
let mut context = Context::default();
let loader = Loader::default();
let js_vm_bin = loader.load_binary("../../deps/ckb-js-vm");
let js_vm_out_point = context.deploy_cell(js_vm_bin);
let js_vm_cell_dep = CellDep::new_builder()
.out_point(js_vm_out_point.clone())
.build();
let js_script_bin = loader.load_binary("../../js/build/fib.bc");
let js_script_out_point = context.deploy_cell(js_script_bin.clone());
let js_script_cell_dep = CellDep::new_builder()
.out_point(js_script_out_point.clone())
.build();
// prepare scripts
let always_success_out_point = context.deploy_cell(ALWAYS_SUCCESS.clone());
let lock_script = context
.build_script(&always_success_out_point.clone(), Default::default())
.expect("script");
let lock_script_dep = CellDep::new_builder()
.out_point(always_success_out_point)
.build();
// prepare cell deps
let cell_deps: Vec<CellDep> = vec![lock_script_dep, js_vm_cell_dep, js_script_cell_dep];
// 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.clone())
.build();
// args: <ckb-js-vm args, 2 bytes> <code_hash to JavaScript code cell, 32 bytes> <hash_type to JavaScript code cell, 1 byte> <JavaScript code args, variable length>
let mut type_script_args: [u8; 35] = [0u8; 35];
let reserved = [0u8; 2];
let (js_cell, _) = context.get_cell(&js_script_out_point.clone()).unwrap();
let js_type_script = js_cell.type_().to_opt().unwrap();
let code_hash = js_type_script.calc_script_hash();
let hash_type = js_type_script.hash_type();
type_script_args[..2].copy_from_slice(&reserved);
type_script_args[2..34].copy_from_slice(code_hash.as_slice());
type_script_args[34..35].copy_from_slice(&hash_type.as_slice());
let type_script = context
.build_script(&js_vm_out_point, type_script_args.to_vec().into())
.expect("script");
let outputs = vec![
CellOutput::new_builder()
.capacity(500u64.pack())
.lock(lock_script.clone())
.type_(Some(type_script.clone()).pack())
.build(),
CellOutput::new_builder()
.capacity(500u64.pack())
.lock(lock_script)
.build(),
];
// prepare output cell data
let outputs_data = vec![Bytes::new(), Bytes::new()];
// build transaction
let tx = TransactionBuilder::default()
.cell_deps(cell_deps)
.input(input)
.outputs(outputs)
.outputs_data(outputs_data.pack())
.build();
let tx = tx.as_advanced_builder().build();
// run
let cycles = context
.verify_tx(&tx, MAX_CYCLES)
.expect("pass verification");
println!("consume cycles: {}", cycles);
}