CKB JS VM: Mechanism and Capabilities
The Quick Start guide mentions that JS contracts run inside the ckb-js-vm. This document provides a detailed explanation of ckb-js-vm.
Introduction
CKB contracts are based on the RISC-V architecture, which means most programming languages can be used to develop contracts for CKB. Currently, Rust and C are the most widely used languages for contract development. However, both have relatively high barriers to entry, which discourages many developers from getting started.
We hope to expand support to more languages in order to lower the entry threshold for developers.
Most beginner-friendly languages, such as Python and JavaScript, are interpreted languages. For CKB-VM, this means they must be interpreted and executed inside the virtual machine, which adds an additional layer of complexity.
JavaScript has long been one of the most popular programming languages. In the past, we attempted to run JavaScript on CKB using Duktape, a lightweight JavaScript engine. However, due to poor performance, this effort was not continued.
Later, we developed ckb-lua-vm. From a technical perspective, Lua is well-suited for contract development and offers acceptable performance. Unfortunately, Lua's limited popularity prevented it from gaining widespread adoption.
Building on these past experiences, we created ckb-js-vm, which is based on QuickJS, and built a full set of supporting tools, including:
- JavaScript bindings with TypeScript libraries
- A JavaScript testing framework:
ckb-testtool
(QuickJS is a small and embeddable JavaScript engine. It was created by Fabrice Bellard, the author of QEMU and the original developer of FFmpeg.)
Build
As a C-language contract, ckb-js-vm uses a Makefile to manage its build process and relies on LLVM 18 by default (Theoretically support higher versions). Developers need to install the dependencies in advance. (LLVM 18 already supports RISC-V compilation, so the official release works.)
git submodule update --init
make all
The contract binary is in build/ckb-js-vm
. The build/ckb-js-vm.debug
binary contains debug symbols and can be used for debugging.
Reproducible Build
When deploying an on-chain script, it's essential to build it from scratch. A key requirement during this process is ensuring that different builds of the same source code produce identical binaries - this is known as a "reproducible build."
You can achieve this with ckb-js-vm using the following command:
bash reproducible_build.sh
Deployment
The script has been deployed on the testnet with these parameters:
Parameter | Value |
---|---|
code_hash | 0x3e9b6bead927bef62fcb56f0c79f4fbd1b739f32dd222beac10d346f2918bed7 |
hash_type | type |
tx_hash | 0xf594e7deb3bdec611b20bdf7814acf7779ddf061a8f207b1f3261242b8dc4494 |
index | 0x0 |
dep_type | code |
The corresponding SHA256 checksum in checksums.txt
is: 898260099d49ef84a1fb10f4aae7dae8ef5ec5b34d2edae29a78981fd084f1ed
ckb-js-vm Command Line Options
When an on-chain script is invoked by exec
or spawn
syscalls, it can accept command line arguments. The
ckb-js-vm supports the following options to control its execution behavior:
-c <filename>
: Compile JavaScript source code to bytecode, making it more efficient for on-chain execution-e <code>
: Execute JavaScript code directly from the command line string-r <filename>
: Read and execute JavaScript code from the specified file-t <target>
: Specify the target resource cell's code_hash and hash_type in hexadecimal format-f
: Enable file system mode, which provides support for JavaScript modules and imports
Note, the -c
and -r
options can only work with ckb-debugger
. The -c
option is particularly useful for preparing
optimized bytecode as described in the previous section. When no options are specified, ckb-js-vm runs in its default
mode. These command line options provide valuable debugging capabilities during development.
Compiling JavaScript into Bytecode
The ckb-js-vm includes built-in functionality for compiling JavaScript code into bytecode, which improves execution efficiency on-chain. You can use this feature as follows:
ckb-debugger --read-file hello.js --bin build/ckb-js-vm -- -c hello.bc
This command:
- Uses
--read-file hello.js
to provide the JavaScript source file to ckb-debugger - Specifies the ckb-js-vm binary with
--bin build/ckb-js-vm
- Passes the
-c hello.bc
option to ckb-js-vm (everything after--
)
The process compiles hello.js
and outputs the bytecode to hello.bc
. The --read-file
option is specific to
ckb-debugger and allows it to read a file as a data source. Command line arguments after the --
separator are passed
directly to the on-chain script, enabling the use of the -c
compilation flag.
Note that this compilation functionality requires the ckb-debugger environment and cannot work independently.
QuickJS bytecode is version-specific and not portable between different QuickJS versions. This compilation approach ensures that generated bytecode is always compatible with the exact QuickJS version used in ckb-js-vm.
ckb-js-vm args
Explanation
The ckb-js-vm
script structure in molecule is below:
code_hash: <code hash of ckb-js-vm, 32 bytes>
hash_type: <hash type of ckb-js-vm, 1 byte>
args: <ckb-js-vm flags, 2 bytes> <code hash of resource cell, 32 bytes> <hash type of resource cell, 1 byte>
The first 2 bytes are parsed into an int16_t
in C using little-endian format (referred to as ckb-js-vm flags). If
the lowest bit of these flags is set (v & 0x01 == 1
), the file system is enabled. File system functionality will be
described in another section.
The subsequent code_hash
and hash_type
point to a resource cell which may contain:
- A file system
- JavaScript source code
- QuickJS bytecode
When the file system flag is enabled, the resource cell contains a file system that can also include JavaScript code.
For most scenarios, QuickJS bytecode is stored in the resource cell. When an on-chain script requires extra args
,
they can be stored beginning at offset 35 (2 + 32 + 1). Compared to normal on-chain scripts in other languages,
ckb-js-vm requires these extra 35 bytes.
Security Best Practices
In this section, we will introduce some background and useful security tips for ckb-js-vm.
Stack and Heap Memory
For normal native C programs, there is no method to control the stack size. However, QuickJS provides this capability
through its JS_SetMaxStackSize
function. This is a critical feature to prevent stack/heap collisions.
Before explaining our memory organization design, let's understand the memory layout of ckb-vm, which follows these rules:
- Total memory is 4M
- From address 0 to the address specified by symbol
_end
, there are ELF sections (.data, .rss, .text, etc.) - The stack begins at 4M and grows backward toward lower addresses
In ckb-js-vm, we carefully organize memory regions as follows:
- From address 0 to
_end
: ELF sections - From address
_end
to 3M: Heap memory for malloc - From address 3M+4K to 4M: Stack
The 4K serves as a margin area. This organization prevents stack/heap collisions when the stack grows too large.
Exit Code
When bytecode or JavaScript code throws an uncaught exception, ckb-js-vm will exit with error code (-1). You can write JavaScript code without explicitly checking for exceptions—simply let them throw naturally.
QuickJS treats every file as a module. Since it implements Top level await, the evaluation result of a module is a promise. This means the code below doesn't return -100 as expected:
function main() {
// ...
return -100;
}
main();
Instead, it unexpectedly returns zero. To ensure your exit code is properly returned, use this pattern instead:
function main() {
// ...
return -100;
}
bindings.exit(main());
Another tip: always write test cases for failure scenarios. Make sure the error codes returned match what you expect in these situations.
Dynamic Loading
JavaScript provides the ability to load modules dynamically at runtime through the evalJsScript
function in the
@ckb-js-std/bindings
package. This powerful feature enables extension mechanisms, plugin architectures, and code
splitting in ckb-js-vm. However, it comes with significant security implications. When modules are loaded from
untrusted sources (such as other cells on-chain), they may contain malicious code. A simple exit(0)
statement
could cause your entire script to exit with a success status, bypassing your validation logic. Bytecode is
particularly problematic as it's extremely difficult to inspect and verify.
If you must use dynamic loading, follow these precautions: only load from trusted sources you control, implement permission restrictions for loaded code, validate module integrity with cryptographic signatures when possible, and consider a pattern like this for safer loading:
// Example of safer dynamic loading with basic validation
function loadModule(moduleSource, allowedAPIs) {
const wrappedSource = `
(function(restrictedBindings) {
${moduleSource}
})({ ...allowedAPIs });
`;
return bindings.evalJsScript(wrappedSource);
}
Remember that even with these safeguards, dynamic loading should be used cautiously in security-critical applications, and avoided entirely when working with untrusted inputs.
Simple File System and Modules
In addition to executing individual JavaScript files, ckb-js-vm also supports JavaScript modules through its Simple File
System. Files within this file system are made available for JavaScript to read, import, and execute, enabling module
imports like import { * } from "./module.js"
. Each Simple File System must contain at least one entry file named
index.bc
(or index.js
), which ckb-js-vm loads from any cell and executes.
A file system is represented as a binary file with a specific format described in this document. You can use the ckb-fs-packer tool to create a file system from your source files or to unpack an existing file system.
How to create a Simple File System
Consider the following two files:
// File index.js
import { fib } from "./fib_module.js";
console.log("fib(10)=", fib(10));
// File fib_module.js
export function fib(n) {
if (n <= 0) return 0;
else if (n == 1) return 1;
else return fib(n - 1) + fib(n - 2);
}
If we want ckb-js-vm to execute this code smoothly, we must package them into a
file system first. To pack them within the current directory into fib.fs
, you
may run
npx ckb-fs-packer pack fib.fs index.js fib_module.js
Note that all file paths provided to fs-packer
must be in relative path format. The absolute path of a file in your
local filesystem is usually meaningless within the Simple File System.
You can also rename files when adding them to the filesystem by using the source:destination
syntax:
npx ckb-fs-packer pack archive.fs 1.js:lib/1.js 2.js:lib/2.js
In this example, the local files 1.js
and 2.js
will be stored in the Simple File System as lib/1.js
and
lib/2.js
respectively.
How to deploy and use Simple File System
While it's often more resource-efficient to write all JavaScript code in a single file, you can enable file system support in ckb-js-vm through either:
- Executing or spawning ckb-js-vm with the "-f" parameter
- Using ckb-js-vm flags with file system enabled (see the Working with ckb-js-vm chapter for details)
Unpacking a Simple File System
To extract files from an existing file system, run:
npx ckb-fs-packer unpack fib.fs .
Simple File System On-disk Representation
The on-disk representation of a Simple File System consists of three parts:
- A file count: A number representing the total files contained in the file system
- Metadata array: Stores information about each file's name and content
- Payload array: Binary objects (blobs) containing the actual file contents
Each metadata entry contains offset and length information for both a file's name and content. For each file, the
metadata stores four uint32_t
values:
- The offset of the file name in the payload array
- The length of the file name
- The offset of the file content in the payload array
- The length of the file content
We can represent these structures using C-like syntax:
struct Blob {
uint32_t offset;
uint32_t length;
}
struct Metadata {
struct Blob file_name;
struct Blob file_content;
}
struct SimpleFileSystem {
uint32_t file_count;
struct Metadata metadata[..];
uint8_t payload[..];
}
When serializing the file system into a file, all integers are encoded as a 32-bit little-endian number. The file names are stored as null terminated strings.
QuickJS Null Termination Workaround
Due to an issue in QuickJS, JavaScript source code strings must be
null-terminated. To address this requirement, ckb-js-vm automatically adds a null byte (\0
) to every file without
including it in the reported length
value.
For example, consider this simple JavaScript code:
console.log("hi");
While the content length is 17 characters, when cast to a C-style string (const char*
), an additional \0
character
is appended after the final )
character. This ensures QuickJS can properly process the source code.
Using init.bc/init.js Files
The ckb-js-vm supports special initialization files named init.bc
or init.js
that are loaded and executed before
index.bc
or index.js
. This feature helps solve issues related to JavaScript module hoisting.
Consider this example code:
import * as bindings from "@ckb-js-std/bindings";
bindings.mount(2, bindings.SOURCE_CELL_DEP, "/");
import * as module from "./fib_module.js";
Due to JavaScript's hoisting behavior, import statements are processed before other code executes. The code effectively becomes:
import * as bindings from "@ckb-js-std/bindings";
import * as module from "./fib_module.js";
bindings.mount(2, bindings.SOURCE_CELL_DEP, "/");
This will fail because the import attempts to access ./fib_module.js
before the file system is mounted. To solve this
problem, place the bindings.mount
statement in an init.bc
or init.js
file, which will execute before any imports are
processed in the main file.
Injecting Functions
During development, functions like loadScript
are commonly used. Most projects will import the dependency @ckb-js-std/bindings
. However, this module does not provide actual implementations of these functions — it's only intended for type checking and IDE code hints.
You can refer to Examples/call-syscalls, which contains a JS script that can be executed directly inside the JS-VM:
import { loadScript } from "@ckb-js-std/bindings";
console.log(loadScript);
console.log(new Uint8Array(loadScript(0, 8)));
console.log("End");
output:
Script log: Run from file, local access enabled. For Testing only.
Script log: function loadScript() {
[native code]
}
Script log: 53,0,0,0,16,0,0,0
Script log: End
Run result: 0
All cycles: 3738804(3.6M)
In this example, @ckb-js-std/bindings
is imported directly. Internally, this module is registered within the C code of the JS-VM using JS_NewCModule
in qjs.c
. The actual implementations are placed in ckb_module.c
.
If developers have specific needs, they can inject custom functions in a similar way. However, we generally do not recommend this approach:
- You would need to publish (i.e., deploy on-chain) the modified JS-VM, which consumes a significant amount of CKB and does not bring substantial performance benefits.
- If you're modifying C code anyway, it's often better to use
spawn
or evenexec
as alternative solutions.