Skip to main content

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:

ParameterValue
code_hash0x3e9b6bead927bef62fcb56f0c79f4fbd1b739f32dd222beac10d346f2918bed7
hash_typetype
tx_hash0xf594e7deb3bdec611b20bdf7814acf7779ddf061a8f207b1f3261242b8dc4494
index0x0
dep_typecode

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:

  1. Uses --read-file hello.js to provide the JavaScript source file to ckb-debugger
  2. Specifies the ckb-js-vm binary with --bin build/ckb-js-vm
  3. 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:

  1. A file system
  2. JavaScript source code
  3. 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:

  1. A file count: A number representing the total files contained in the file system
  2. Metadata array: Stores information about each file's name and content
  3. 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 even exec as alternative solutions.