Skip to main content

Build a Simple Lock

Tutorial Overview

⏰ Estimated Time: 10 - 15 min
💡 Topics: Full-Stack, Lock-Script
🔧 Tools You Need:

In this tutorial, we'll explore how to create a full-stack dApp, encompassing both the frontend and the smart contract, to gain a deeper understanding of CKB blockchain development.

Our dApp will be a straightforward toy lock example. We'll construct a lock script, referred to as hash_lock, and employ this lock to protect some CKB tokens. Additionally, we'll develop a web interface that enables users to transfer tokens from this hash_lock.

The hash_lock is a basic toy project. You specify a hash in the hash_lock script's script_args. To unlock this script, users must supply the preimage to reveal the hash.

note

While this toy lock example isn't suitable for production use, it serves as an excellent starting point for learning.

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 https://github.com/nervosnetwork/docs.nervos.org.git
cd docs.nervos.org/examples/simple-lock

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 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. If the preimage is correct, the transaction will be accepted on chain. If not, the transaction will fail because our small 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 are matched, it returns 0 as success; otherwise, it fails.

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

simple-lock/contracts/hash-lock/src/main.rs
#![no_std]
#![cfg_attr(not(test), no_main)]

#[cfg(test)]
extern crate alloc;

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

#[cfg(not(test))]
use ckb_std::default_alloc;
use ckb_std::error::SysError;
#[cfg(not(test))]
ckb_std::entry!(program_entry);
#[cfg(not(test))]
default_alloc!();

#[repr(i8)]
pub enum Error {
IndexOutOfBound = 1,
ItemMissing,
LengthNotEnough,
Encoding,
// Add customized errors here...
CheckError,
UnMatch,
}

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
.lock()
.to_opt()
.ok_or(Error::CheckError)?
.raw_data();

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

if hash.eq(&expect_hash.as_ref()) {
Ok(())
} else {
Err(Error::UnMatch)
}
}

Couple of 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 filed in the CKB transaction. The structure used in the witness filed 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.
  • Then we compare the two hash values to see if they match (hash.eq(&expect_hash.as_ref())). If not, 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 smart contract in our dApp then? Let's check the frontend code.

DApp to use the Hash_Lock Script

Let's check 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 guard CKB tokens.

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

simple-lock/frontend/app/hash-lock.ts
// ...
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 {
address,
lockScript,
};
}
// ...

Another thing worth mentioning is that the generateAccount function also returns a CKB address. This address is computed from the lock script using Lumos utils helpers.encodeToAddress(lockScript). The CKB address is essentially just the encoding 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 works 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 talking about how much money a specific lock seals. And that's how the balance (capacities) calculation function works in our frontend code --- by collecting the live cells which use a specific lock script and summing their capacities.

simple-lock/frontend/app/hash-lock.ts
// ...
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 correct 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.

simple-lock/frontend/app/hash-lock.ts
// ...
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 https://medium.com/nervosnetwork/understanding-the-nervos-dao-and-cell-model-d68f38272c24`
);
}

// 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);
collected.push(cell);
if (collectedSum.gte(neededCapacity)) break;
}

if (collectedSum.lt(neededCapacity)) {
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) =>
inputs.push(...collected)
);
txSkeleton = txSkeleton.update("outputs", (outputs) =>
outputs.push(transferOutput)
);
txSkeleton = txSkeleton.update("cellDeps", (cellDeps) =>
cellDeps.push({
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 the hash and preimage approach is too simplistic to function as a secure lock script in our example, it serves a valuable purpose as a learning starting point. The main idea is to learn the concepts and principles of how CKB scripts work and gain experience with CKB development.


Congratulations!

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 README.md.

Additional Resources