Skip to main content

Build a Simple Lock

Tutorial Overview

⏰ Estimated Time: 10 - 15 min
πŸ”§ Tools You Need:
  • An IDE/Editor that supports TypeScript
  • CKB dev environment: OffCKB
  • Script develop tools
  • git,make,sed,bash,sha256sum and others Unix utilities.
  • Rust and riscv64 target: rustup target add riscv64imac-unknown-none-elf
  • Clang 16+
    • Debian / Ubuntu: wget && chmod +x && sudo ./ 16 && rm
    • Fedora 39+: sudo dnf -y install clang
    • Archlinux: sudo pacman --noconfirm -Syu clang
    • MacOS (with Homebrew): brew install llvm@16
    • Windows (with Scoop): scoop install llvm yasm

In this tutorial, we'll show you how to create a full-stack dApp, including both the frontend and the Script, to help you better understand CKB blockchain development.

Our example dApp will use a simple toy lock. We'll create a Lock Script

named hash_lock to secure some CKB tokens and build a web interface for users to transfer tokens from this hash_lock.

The hash_lock project involves specifying a hash in the Script's script_args

. To unlock it, users must provide the preimage that matches the hash.


Although this toy lock example isn't intended for production, it's an excellent starting point for learning the basics.

Setup Devnet & Run Example​

Step 1: Clone the Repository​

To get started with the tutorial dApp, clone the repository and navigate to the appropriate directory using the following commands:

git clone

Step 2: Start the Devnet​

To interact with the dApp, ensure that your Devnet is up and running. After installing @offckb/cli, open a terminal and start the Devnet with the following command:

offckb node

You might want to check pre-funded accounts and copy private keys for later use. Open another terminal and execute:

offckb accounts

Step 3: Build and Deploy the Script​

Navigate to your project, compile and deploy the Script to Devnet.

Compile the Script:

make build

Deploy the Script binary to the Devnet:

cd frontend && offckb deploy --network devnet

Step 4: Run the DApp​

Navigate to your project's frontend folder, install the node dependencies, and start running the example:

cd frontend && npm run dev

Now, the app is running in http://localhost:3000

Step 5: Deposit Some CKB​

With our dApp up and running, you can now input a hash value to construct a hash_lock Script. To utilize this hash_lock Script, we need to prepare some Live Cells

that use this Script as their Lock Script. This preparation process is known as deposit. We can use offckb to quickly deposit to any CKB address. Here's an example of how to deposit 100 CKB into a specific address:

offckb deposit --network devnet ckt1qry2mh3j5cylve2tl2sjpg3zhp9wjeq2l92rvxtd2scsx4jks500xpqrnm4k4g7j8nlnyc0j3y3z5q6s5ns29k8wx9prkn8ff09mhepmagkhur6h 10000000000

Once you've deposited some CKB into the hash_lock CKB address, you can attempt to transfer some balance from this address to another. This will allow you to test the hash_lock Script and see if it functions as expected.

You can try clicking the "Transfer" button. The website will prompt you to enter the preimage value. If the preimage is correct, the transaction will be accepted on-chain. If not, the transaction will fail because our hash_lock Script rejects the incorrect preimage and works as expected.

Behind the Scene​

Script Logic​

The concept behind hash_lock is to specify a hash in a particular Script. To unlock this Script, you must provide the preimage to reveal the hash. More specifically, the hash_lock Script will execute the following validations on-chain:

  • First, the Script will read the preimage value from the transaction witness field.
  • Second, the Script will take the preimage and hash it using ckb-default-hash based on blake-2b-256.
  • Lastly, the Script will compare the hash generated from the preimage with the hash value from the script_args. If the two match, it returns 0 as success; otherwise, it fails.

To gain a better understanding, let's examine the full Script code. Open the file in the contracts folder:

#![cfg_attr(not(test), no_main)]

extern crate alloc;

use ckb_hash::blake2b_256;
use ckb_std::ckb_constants::Source;

use ckb_std::default_alloc;
use ckb_std::error::SysError;

pub enum Error {
IndexOutOfBound = 1,
// Add customized errors here...

impl From<SysError> for Error {
fn from(err: SysError) -> Self {
match err {
SysError::IndexOutOfBound => Self::IndexOutOfBound,
SysError::ItemMissing => Self::ItemMissing,
SysError::LengthNotEnough(_) => Self::LengthNotEnough,
SysError::Encoding => Self::Encoding,
SysError::Unknown(err_code) => panic!("unexpected sys error {}", err_code),

pub fn program_entry() -> i8 {
ckb_std::debug!("This is a sample contract!");

match check_hash() {
Ok(_) => 0,
Err(err) => err as i8,

pub fn check_hash() -> Result<(), Error> {
let script = ckb_std::high_level::load_script()?;
let expect_hash = script.args().raw_data().to_vec();

let witness_args = ckb_std::high_level::load_witness_args(0, Source::GroupInput)?;
let preimage = witness_args

let hash = blake2b_256(preimage.as_ref());

if hash.eq(&expect_hash.as_ref()) {
} else {

Here are a few things to note:

  • In the check_hash() function, we use ckb_std::high_level::load_witness_args syscalls to read the preimage value from the witness fieled in the CKB transaction. The structure used in the witness fieled here is the witnessArgs.
  • We then use the CKB default hash function blake2b_256 from the use ckb_hash::blake2b_256 library to hash the preimage and get its hash value.
  • Next, we compare the two hash values to see if they match (hash.eq(&expect_hash.as_ref())). If they do not match, we return a custom error code Error::UnMatch (which is 6).

The whole logic is quite simple and straightforward. How do we use such a Script in our dApp? Let's check the frontend code.

Use the Hash_Lock Script in Your DApp​

Let's take a look at the generateAccount function: It accepts a hash string parameter. This hash string is used as script_args to build a hash_lock Script. This Script can then be used as the lock to secure CKB tokens.

Note that we can directly use offCKB.lumosConfig.SCRIPTS.HASH_LOCK to get the code_hash and hash_type information, thanks to the offckb templates.

// ...
export function generateAccount(hash: string) {
const lockArgs = "0x" + hash;
const lockScript = {
codeHash: offCKB.lumosConfig.SCRIPTS.HASH_LOCK!.CODE_HASH,
hashType: offCKB.lumosConfig.SCRIPTS.HASH_LOCK!.HASH_TYPE,
args: lockArgs,
const address = helpers.encodeToAddress(lockScript, {
config: offCKB.lumosConfig,
return {
// ...

Another important aspect of the generateAccount function is that it also returns a CKB address. This address is computed from the Lock Script using Lumos utils helpers.encodeToAddress(lockScript). Essentially, the CKB address is just the encoded version of the Lock Script.

Think of it like a safe deposit box. The address is like the serial number of the lock (used to identify the lock) on top of the safe box. When you deposit CKB tokens into a CKB address, it's like depositing money into a specific safe box with a specific lock.

When we talk about how much balance a CKB address holds, we're simply referring to how much value a specific lock secures. The balance (capacities) calculation function in our frontend code works by collecting the Live Cells that use a specific Lock Script and summing their capacities.

// ...
export async function capacityOf(address: string): Promise<BI> {
const collector = indexer.collector({
lock: helpers.parseAddress(address, { config: lumosConfig }),

let balance = BI.from(0);
for await (const cell of collector.collect()) {
balance = balance.add(cell.cellOutput.capacity);

return balance;
// ...

To transfer (or unlock) CKB from this hash_lock Script address, we need to build a CKB transaction that consumes some Live Cells which use the hash_lock Script and generates new Live Cells which use the receiver's Lock Script. Additionally, in the witness field of the transaction, we need to provide the preimage for the hash value in the hash_lock Script args to prove that we are indeed the owner of the hash_lock Script (since only the owner knows the preimage).

We use Lumos' transactionSkeleton to build such a transaction.

// ...
export async function unlock(
fromAddr: string,
toAddr: string,
amountInShannon: string
): Promise<string> {
const { lumosConfig, indexer, rpc } = offCKB;
let txSkeleton = helpers.TransactionSkeleton({});
const fromScript = helpers.parseAddress(fromAddr, {
config: lumosConfig,
const toScript = helpers.parseAddress(toAddr, { config: lumosConfig });

if (BI.from(amountInShannon).lt(BI.from("6100000000"))) {
throw new Error(
`every cell's capacity must be at least 61 CKB, see`

// additional 0.001 ckb for tx fee
// the tx fee could calculated by tx size
// this is just a simple example
const neededCapacity = BI.from(amountInShannon).add(100000);
let collectedSum = BI.from(0);
const collected: Cell[] = [];
const collector = indexer.collector({ lock: fromScript, type: "empty" });
for await (const cell of collector.collect()) {
collectedSum = collectedSum.add(cell.cellOutput.capacity);
if (collectedSum.gte(neededCapacity)) break;

if ( {
throw new Error(`Not enough CKB, ${collectedSum} < ${neededCapacity}`);

const transferOutput: Cell = {
cellOutput: {
capacity: BI.from(amountInShannon).toHexString(),
lock: toScript,
data: "0x",

txSkeleton = txSkeleton.update("inputs", (inputs) =>
txSkeleton = txSkeleton.update("outputs", (outputs) =>
txSkeleton = txSkeleton.update("cellDeps", (cellDeps) =>
outPoint: {
txHash: lumosConfig.SCRIPTS.HASH_LOCK!.TX_HASH,
index: lumosConfig.SCRIPTS.HASH_LOCK!.INDEX,
depType: lumosConfig.SCRIPTS.HASH_LOCK!.DEP_TYPE,

const preimageAnswer = window.prompt("please enter the preimage: ");
if (preimageAnswer == null) {
throw new Error("user abort input!");

const newWitnessArgs: WitnessArgs = {
lock: stringToBytesHex(preimageAnswer),
console.log("newWitnessArgs: ", newWitnessArgs);
const witness = bytes.hexify(blockchain.WitnessArgs.pack(newWitnessArgs));
txSkeleton = txSkeleton.update("witnesses", (witnesses) =>
witnesses.set(0, witness)

const tx = helpers.createTransactionFromSkeleton(txSkeleton);
const hash = await rpc.sendTransaction(tx, "passthrough");
console.log("Full transaction: ", JSON.stringify(tx, null, 2));

return hash;

Is the Hash_Lock Safe to Use?​

The short answer is no. The hash_lock is not very secure for guarding your CKB tokens. Some of you might already know the reason, but here are some points to consider:

  • Miner Front-running: Since the preimage value is revealed in the witness, once you submit the transaction to the blockchain, miners can see this preimage and construct a new transaction to transfer the tokens to their addresses before you do.
  • Balance Vulnerability: Once you transfer part of the balance from the hash_lock address, the preimage value is revealed on-chain. This makes the remaining tokens locked in the hash_lock vulnerable since anyone who sees the preimage can steal them.

Even though using a hash and preimage is too simple to be a secure Lock Script, it’s a great starting point for learning. The goal is to understand how CKB Scripts work and gain experience with CKB development.


By following this tutorial, you've mastered the basics of building a custom lock and a full-stack dApp on CKB. Here's a quick recap:

  • We built a custom Lock Script to guard CKB tokens.
  • We built a dApp frontend to transfer/unlock tokens from this Lock Script.
  • We explored the limitations and vulnerabilities of our naive Lock Script design.

Next Step​

So now your dApp works great on the local blockchain, you might want to switch it to different environments like Testnet and Mainnet.

To do that, just change the environment variable NETWORK to testnet in the .env file:

NEXT_PUBLIC_NETWORK=testnet # devnet, testnet or mainnet

For more details, check out the

Additional Resources​