Build a Simple Lock
- CKB dev environment: OffCKB (β₯v0.3.0)
- JavaScript SDK: CCC (β₯v0.1.0-alpha.4)
Tutorial Overviewβ
In this tutorial, you will learn how to create a full-stack dApp, including both the frontend and the Script, to deepen your understanding of CKB blockchain development.
Our example dApp will use a simple toy lock. You will create a Lock Scripthash_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
This toy lock example isn't intended for production. Use it ONLY n Testnet as an 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 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:
- Command
- Response
offckb node
/bin/sh: /Users/nervosDocs/.nvm/versions/node/v18.12.1/lib/node_modules/@offckb/cli/target/ckb/ckb: No such file or directory
/Users/nervosDocs/.nvm/versions/node/v18.12.1/lib/node_modules/@offckb/cli/target/ckb/ckb not found, download and install the new version 0.113.1..
CKB installed successfully.
init Devnet config folder: /Users/nervosDocs/.nvm/versions/node/v18.12.1/lib/node_modules/@offckb/cli/target/devnet
modified /Users/nervosDocs/.nvm/versions/node/v18.12.1/lib/node_modules/@offckb/cli/target/devnet/ckb-miner.toml
CKB output: 2024-03-20 07:56:44.765 +00:00 main INFO sentry sentry is disabled
CKB output: 2024-03-20 07:56:44.766 +00:00 main INFO ckb_bin::helper raise_fd_limit newly-increased limit: 61440
CKB output: 2024-03-20 07:56:44.854 +00:00 main INFO ckb_bin::subcommand::run ckb version: 0.113.1 (95ad24b 2024-01-31)
CKB output: 2024-03-20 07:56:45.320 +00:00 main INFO ckb_db_migration Init database version 20230206163640
CKB output: 2024-03-20 07:56:45.329 +00:00 main INFO ckb_launcher Touch chain spec hash: Byte32(0x3036c73473a371f3aa61c588c38924a93fb8513e481fa7c8d884fc4cf5fd368a)
You might want to check pre-funded accounts and copy private keys for later use. Open another terminal and execute:
- Command
- Response
offckb accounts
Print account list, each account is funded with 42_000_000_00000000 capacity in the genesis block.
[
{
privkey: '0x6109170b275a09ad54877b82f7d9930f88cab5717d484fb4741ae9d1dd078cd6',
pubkey: '0x02025fa7b61b2365aa459807b84df065f1949d58c0ae590ff22dd2595157bffefa',
lockScript: {
codeHash: '0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8',
hashType: 'type',
args: '0x8e42b1999f265a0078503c4acec4d5e134534297'
},
address: 'ckt1qzda0cr08m85hc8jlnfp3zer7xulejywt49kt2rr0vthywaa50xwsqvwg2cen8extgq8s5puft8vf40px3f599cytcyd8',
args: '0x8e42b1999f265a0078503c4acec4d5e134534297'
},
{
privkey: '0x9f315d5a9618a39fdc487c7a67a8581d40b045bd7a42d83648ca80ef3b2cb4a1',
pubkey: '0x026efa0579f09cc7c1129b78544f70098c90b2ab155c10746316f945829c034a2d',
lockScript: {
codeHash: '0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8',
hashType: 'type',
args: '0x758d311c8483e0602dfad7b69d9053e3f917457d'
},
address: 'ckt1qzda0cr08m85hc8jlnfp3zer7xulejywt49kt2rr0vthywaa50xwsqt435c3epyrupszm7khk6weq5lrlyt52lg48ucew',
args: '0x758d311c8483e0602dfad7b69d9053e3f917457d'
},
#...
]
Step 3: Build and Deploy the Scriptβ
Navigate to your project, compile and deploy the Script to Devnet.
Compile the Script:
- Command
- Response
make build
Cleaning build/release directory...
mkdir -p build/release
RUSTFLAGS="-C target-feature=+zba,+zbb,+zbc,+zbs --cfg debug_assertions" TARGET_CC="clang"
cargo build --target=riscv64imac-unknown-none-elf --release
Finished release [optimized] target(s) in 0.22s
Copying binary hash-lock to build directory
Deploy the Script binary to the Devnet:
- Command
- Response
cd frontend && offckb deploy --network devnet
contract HASH-LOCK deployed, tx hash: 0x9f55da2b555cdc4412945ff8827b7e77508c84f0b85d82b027d31418e6a9b5d9
wait 4 blocks..
done.
Step 4: Run the DAppβ
Navigate to your project's frontend folder, install the node dependencies, and start running the example:
- Command
- Response
cd frontend && npm i && npm run dev
> frontend@0.1.0 dev
> next dev
β² Next.js 14.2.3
- Local: http://localhost:3000
- Environments: .env
β Starting...
β Ready in 1631ms
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 Cellsoffckb
to quickly deposit to any CKB address. Here's an example of how to deposit 100 CKB into a specific address:
- Command
- Response
offckb deposit --network devnet ckt1qry2mh3j5cylve2tl2sjpg3zhp9wjeq2l92rvxtd2scsx4jks500xpqrnm4k4g7j8nlnyc0j3y3z5q6s5ns29k8wx9prkn8ff09mhepmagkhur6h 10000000000
tx hash: 0x0668292c875ee31906e48651a553a16158307c02f2e91d24be75166ca080e1fd
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 onblake-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 main.rs
file in the contracts
folder:
#![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)
}
}
Here are a few things to note:
- In the
check_hash()
function, we useckb_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 thewitnessArgs
. - We then use the CKB default hash function
blake2b_256
from theuse 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 codeError::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.myScripts["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.myScripts["hash-lock"]!.codeHash,
hashType: offCKB.myScripts["hash-lock"]!.hashType,
args: lockArgs,
};
const address = ccc.Address.fromScript(lockScript, cccClient).toString();
return {
address,
lockScript: ccc.Script.from(lockScript),
};
}
// ...
Another important aspect of the generateAccount
function is that it also returns a CKB address. This address is computed from the Lock Script using CCC utils ccc.Address.fromScript
. 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<bigint> {
const addr = await ccc.Address.fromString(address, cccClient);
let balance = await cccClient.getBalance([addr.script]);
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 CCC to build such a transaction.
// ...
export async function unlock(
fromAddr: string,
toAddr: string,
amountInCKB: string
): Promise<string> {
const fromScript = (await ccc.Address.fromString(fromAddr, cccClient)).script;
const toScript = (await ccc.Address.fromString(toAddr, cccClient)).script;
const readSigner = new ccc.SignerCkbScriptReadonly(cccClient, fromScript);
// Build the full transaction
const tx = ccc.Transaction.from({
outputs: [{ lock: toScript }],
outputsData: [],
});
// CCC transactions are easy to be edited
tx.outputs.forEach((output, i) => {
if (output.capacity > ccc.fixedPointFrom(amountInCKB)) {
alert(`Insufficient capacity at output ${i} to store data`);
return;
}
output.capacity = ccc.fixedPointFrom(amountInCKB);
});
// fill the witness with preimage
const preimageAnswer = window.prompt("please enter the preimage: ");
if (preimageAnswer == null) {
throw new Error("user abort input!");
}
const newWitnessArgs = new ccc.WitnessArgs(
stringToBytesHex(preimageAnswer) as `0x${string}`
);
console.log("newWitnessArgs: ", newWitnessArgs);
tx.setWitnessArgsAt(0, newWitnessArgs);
// Complete missing parts for transaction
await tx.addCellDeps(offCKB.myScripts["hash-lock"]!.cellDeps[0].cellDep);
await tx.completeInputsByCapacity(
readSigner,
ccc.fixedPointFrom(amountInCKB)
);
const balanceDiff =
(await tx.getInputsCapacity(cccClient)) - tx.getOutputsCapacity();
console.log("balanceDiff: ", balanceDiff);
if (balanceDiff > ccc.Zero) {
tx.addOutput({
lock: fromScript,
});
}
//await tx.completeFeeBy(readSigner, 1000);
const txHash = await cccClient.sendTransaction(tx);
console.log("Full transaction: ", tx.stringify());
return txHash;
}
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 thehash_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.
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β
- CKB transaction structure: RFC-0022-transaction-structure
- CKB data structure basics: RFC-0019-data-structure