Build a Simple Lock
Tutorial Overview
- An IDE/Editor that supports TypeScript
- CKB dev environment: OffCKB
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.
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:
- 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 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 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:
- 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. 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 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 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:
#![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 useckb_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 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. - 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 codeError::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.
// ...
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.
// ...
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.
// ...
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 thehash_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
- CKB transaction structure: RFC-0022-transaction-structure
- CKB data structure basics: RFC-0019-data-structure