Skip to main content

JavaScript Quick Start

⏰ Estimated Time: 5 - 7 min
🔧 What You Will Need:
For detailed installation steps, refer to our Installation Guide

Before proceeding, you should be familiar with:

  • CKB basics and the transaction structures
  • JavaScript/TypeScript

This guide helps you get started writing CKB contracts in JavaScript as quickly as possible. The goal is to get a working demo running, not to dive deep into implementation details. For in-depth exploration, refer to CKB JS VM.

Hello World

You can directly run a snippet of JavaScript code using ckb-debugger:

ckb-debugger --bin ckb-js-vm -- -e "console.log(\"hello, ckb-js-script\!\");"

Output:

Script log: hello, ckb-js-script!
Run result: 0
All cycles: 3013129(2.9M)

Explanation:

  • --bin specifies the binary to use. Here, it should point to ckb-js-vm, which you can either compile yourself or use the one at node_modules/ckb-testtool/src/unittest/defaultScript/ckb-js-vm.
  • -- separates the debugger’s arguments from those passed to ckb-js-vm.
  • -e is an argument for ckb-js-vm that runs the provided JavaScript code.

(You can run ckb-debugger --bin ckb-js-vm -- -h to see all available options for ckb-js-vm.)

This is a minimal example to verify that JavaScript code can run in CKB-VM. To create an actual on-chain contract, we need more structure. Let’s build a complete, deployable contract project.

Create Project

note
  • It is recommended to use TypeScript rather than JavaScript for writing contracts. All examples in this tutorial will use TypeScript.
  • Project structure and package managers are not restricted. (ckb-js-vm uses pnpm by default and provides a project template, which will be covered below.)
  • This tutorial will omit general setup details, such as configuring the TypeScript environment.
  • The test framework used is Jest.

Use the command below to scaffold a default project:

pnpm create ckb-js-vm-app

By default, this generates two sub-projects:

  • Contracts: packages/on-chain-script
  • Tests: packages/on-chain-script-tests

(In the examples, multiple contracts are placed under the contracts directory.)

Modify contracts/hello-wrold/src/index.ts:

import * as bindings from "@ckb-js-std/bindings";
function main(): number {
console.log("Hello World!");
return 0;
}
bindings.exit(main());
  • Return values must be passed using bindings.exit.

Build

The template is pre-configured, so you can simply run:

pnpm build

Here's what happens under the hood:

tsc --noEmit
esbuild --platform=neutral --minify --bundle --external:@ckb-js-std/bindings --target=es2022 src/index.ts --outfile=dist/index.js
ckb-debugger --read-file dist/index.js --bin node_modules/ckb-testtool/src/unittest/defaultScript/ckb-js-vm -- -c dist/index.bc

Explanation:

  • tsc is used to validate types with tsconfig.base.json.
  • esbuild compiles and bundles the source.
  • ckb-js-vm compiles the JS file to bytecode for better performance and lower cycle consumption.

Once compiled, you'll get two files in ./dist:

  • *.js: The bundled and minified JavaScript
  • *.bc: The compiled bytecode

Both formats are executable by ckb-js-vm, but with trade-offs:

  • .js is ~10% smaller in size
  • .bc reduces cycles by ~80% during execution

Note: These numbers vary with project complexity. For example, in SilentBerry, the AccountBook contract in TypeScript had a 63 KB JS file and a 70 KB bytecode file. During the test_simple_withdrawal_suc test, Rust used 4.04M cycles, bytecode 36.86M, and JS 117.85M.

To execute:

ckb-debugger --read-file dist/hello-world.bc --bin deps/ckb-js-vm -- -r

Output:

Script log: Run from file, local access enabled. For Testing only.
Script log: Hello World!
Run result: 0
All cycles: 3579033(3.4M)

Test

In the example above, we use ckb-debugger to run the script. For realistic testing, we simulate on-chain behavior using ckb-testtool and ckb-ccc:

In packages/on-chain-script-tests/src/index.test.ts:

import { Resource, Verifier } from "ckb-testtool";
import { hashCkb, hexFrom, Hex, Transaction, WitnessArgs } from "@ckb-ccc/core";
import { readFileSync } from "fs";

import { createJSScript } from "./misc";

const SCRIPT_HELLO_WORLD = readFileSync(
"../../contracts/hello-wrold/dist/index.bc"
);
const SCRIPT_SIMPLE_PRINT_ARGS = readFileSync(
"../../contracts/simple-print-args/dist/index.bc"
);

test("hello-world success", () => {
const resource = Resource.default();
const tx = Transaction.default();
const lockScript = createJSScript(
resource,
tx,
hexFrom(SCRIPT_HELLO_WORLD),
"0x"
);

// mock a input cell with the created script as lock script
const inputCell = resource.mockCell(lockScript);

// add input cell to the transaction
tx.inputs.push(Resource.createCellInput(inputCell));
// add output cell to the transaction
tx.outputs.push(Resource.createCellOutput(lockScript));
// add output data to the transaction
tx.outputsData.push(hexFrom("0x"));

// verify the transaction
const verifier = Verifier.from(resource, tx);
verifier.verifySuccess(true);
});

Here, CKB_JS_VM_SCRIPT is the VM contract and SCRIPT_HELLO_WORLD is your contract code, similar to running JS via node.

The contract is embedded in a transaction using ccc, and verifier.verifySuccess(true) checks that execution succeeds.

ckb-js-vm lock args format:

  • First 2 bytes: loader args (usually 0x0000)
  • Next 32 bytes: code hash of the JS contract
  • Final byte: hash type (Data2 or Type)

Simple print args

Now let’s build on Hello World by adding CKB syscall support. Use @ckb-js-std/core to access script args and witness data:

  1. Duplicate the Hello World project.
  2. Rename the project and update package.json fields.
  3. Add @ckb-js-std/bindings and @ckb-js-std/core.

In index.ts, add:

import * as bindings from "@ckb-js-std/bindings";
import { HighLevel } from "@ckb-js-std/core";

function main() {
const scritpArgs = HighLevel.loadScript().args.slice(35);
console.log(`Script Args: ${new Uint8Array(scritpArgs)}`);

let witness =
HighLevel.loadWitnessArgs(0, bindings.SOURCE_GROUP_INPUT).lock ??
new ArrayBuffer(0);
console.log(`Witness: ${new Uint8Array(witness)}`);

return 0;
}

bindings.exit(main());

Explanation:

  • scriptArgs must skip the first 35 bytes (used by VM loader).
  • witness is retrieved via loadWitnessArgs.

Test

This contract can't be tested directly with ckb-debugger since it requires script args and witness. Use an updated test:

test("simple-print-args success", () => {
const resource = Resource.default();
const tx = Transaction.default();
const lockScript = createJSScript(
resource,
tx,
hexFrom(SCRIPT_SIMPLE_PRINT_ARGS),
"0x010203040506"
);

// mock a input cell with the created script as lock script
const inputCell = resource.mockCell(lockScript);
const witness = WitnessArgs.from({
lock: "0x00112233445566",
});

// add input cell to the transaction
tx.inputs.push(Resource.createCellInput(inputCell));
// add output cell to the transaction
tx.outputs.push(Resource.createCellOutput(lockScript));
// add output data to the transaction
tx.outputsData.push(hexFrom("0x"));
// add witnesses
tx.witnesses.push(hexFrom(witness.toBytes()));

// verify the transaction
const verifier = Verifier.from(resource, tx);
verifier.verifySuccess(true);
});

Here, WitnessArgs is used to structure and encode witness data—this is the recommended approach for contracts sharing witnesses across lock/type scripts.

note

Since ckb-testtool relies on ckb-debugger under the hood, and ckb-debugger uses stdin to receive transaction data, using stdin in a multithreaded context can lead to deadlocks. Therefore, it's necessary to add --maxWorkers=1.