JavaScript Quick Start
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 tockb-js-vm
, which you can either compile yourself or use the one atnode_modules/ckb-testtool/src/unittest/defaultScript/ckb-js-vm
.--
separates the debugger’s arguments from those passed tockb-js-vm
.-e
is an argument forckb-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
- 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
usespnpm
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 withtsconfig.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
orType
)
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:
- Duplicate the Hello World project.
- Rename the project and update
package.json
fields. - 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 vialoadWitnessArgs
.
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.
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
.