View and Transfer a CKB Balance
Tutorial Overview
- An IDE/Editor that supports TypeScript
- CKB dev environment: OffCKB
How Transaction Works
CKB is based on a UTXO-like Cell Model. Every Cell has a capacity limit, which represents both the CKB balance and how much data can be stored in the Cell simultaneously. A Cell can store any type of data.
A transaction in CKB works similarly to Bitcoin. Each transaction consumes some input Cells and produces new output Cells. Note that the total capacities of the output Cells cannot be larger than those of the input Cells, similar to how UTXOs are transferred and converted in Bitcoin.
Setup Devnet & Run Example
Step 1: Initialize
After installing @offckb/cli, run the following command to initlize a project with our built-in templates.
offckb init <project-name>
When prompted to select a dApp template, use your arrow keys to select View and Transfer a CKB Balance for this tutorial.
- Command
- Response
? Select a dApp template (Use arrow keys)
> View and Transfer a CKB Balance
Write an On-Chain Message
Create a Fungible Token
Create a Digital Object Using Spore Protocol
A simple dApp to check CKB balance and transfer CKB
init CKB dApp project: /Users/ckb/Desktop/offckb/<project-name>
✨ Done in 2.52s.
Step 2: Start the Devnet
To interact with the dApp, you need to have your Devnet running. Open one terminal and start the Devnet:
- 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: Run the Example
Navigate to your project, install the node dependencies, and start running the example:
- Command
- Response
cd <project-name> && yarn && yarn start
$ parcel index.html
Server running at http://localhost:1234
✨ Built in 66ms
Now, the app is running in http://localhost:1234
Behind the Scene
Open the lib.ts
file in your project and check out the generateAccountFromPrivateKey
function:
export const generateAccountFromPrivateKey = (privKey: string): Account => {
const pubKey = hd.key.privateToPublic(privKey);
const args = hd.key.publicKeyToBlake160(pubKey);
const template = lumosConfig.SCRIPTS["SECP256K1_BLAKE160"]!;
const lockScript = {
codeHash: template.CODE_HASH,
hashType: template.HASH_TYPE,
args: args,
};
const address = helpers.encodeToAddress(lockScript, { config: lumosConfig });
return {
lockScript,
address,
pubKey,
};
};
What this function does is generate the account's public key and address via a private key. Here, we need to construct and encode a lock script to obtain the corresponding address of this account. A lock script ensures that only the owner can consume their Live Cells.
Here, we use the CKB standard lock script template, combining the SECP256K1 signing algorithm with the BLAKE160 hashing algorithm, to build such a lock script. Note that different templates will yield different addresses when encoding the address, corresponding to different types of guard for the assets.
Once we have the lock script of an account, we can determine how much balance the account has. The calculation is straightforward; we query and find all the Cells that use the same lock script and sum all these Cells' capacities; the sum is the balance.
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;
}
In Nervos CKB, Shannon is the smallest currency unit, with 1 CKB equaling 10^8 Shannon. This unit system is similar to Bitcoin's Satoshis, where 1 Bitcoin = 10^8 Satoshis. Note that in this tutorial, we use only the Shannon unit.
Next, we can start to transfer balance. Check out the transfer function in lib.ts
:
//CKB To Shannon
interface Options {
from: string;
to: string;
amount: string;
privKey: string;
}
export async function transfer(options: Options): Promise<string>;
The transfer
function accepts an Option
parameter, which includes necessary information about the transfer such as fromAddress
, toAddress
, amount
, and the privateKey
to sign the transfer transaction.
What this transfer transaction does is collect and consume as many capacities as needed with some Live Cells as the input Cells and produce some new output Cells. The lock script of all these new Cells is set to the new owner's lock script for another account. In this way, the CKB balance is transferred from one account to another, marking the transition of Cells from old to new.
Next, let's build the transaction for transferring the balance. The first step is to create an empty txSkeleton
.
let txSkeleton = helpers.TransactionSkeleton({});
Then we determine the total capacities required for our transaction including Transfer Amount + Transaction Fee
, here we set the transaction fee as 100000
Shannon.
const neededCapacity = BI.from(options.amount).add(100000);
Then we retrieve the sender account's assets from blockchain RPC with the help of indexer
and collect the transaction's inputs Cells
const fromScript = helpers.parseAddress(options.from, {
config: lumosConfig,
});
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 >= neededCapacity) break;
}
if (collectedSum.lt(neededCapacity)) {
throw new Error(`Not enough CKB, ${collectedSum} < ${neededCapacity}`);
}
Now, let's create the transaction's output Cells:
transferOutput
: Generated based on the desired transfer amount by the user.changeOutput
: Represents the remaining balance after completing the transaction.
const toScript = helpers.parseAddress(options.to, { config: lumosConfig });
const transferOutput: Cell = {
cellOutput: {
capacity: BI.from(options.amount).toHexString(),
lock: toScript,
},
data: "0x",
};
const changeOutput: Cell = {
cellOutput: {
capacity: collectedSum.sub(neededCapacity).toHexString(),
lock: fromScript,
},
data: "0x",
};
Then, we need to add Inputs and Outputs to the created txSkeleton
. Additionally, we add Cell Deps
, which contain an OutPoint
pointing to some specific Live Cells. These Cells are related to the transaction and can be used as dependencies to place code that will be loaded and executed by the ckb-vm
or to place data that can be used for on-chain script execution.
txSkeleton = txSkeleton.update("inputs", (inputs) => inputs.push(...collected));
txSkeleton = txSkeleton.update("outputs", (outputs) =>
outputs.push(transferOutput, changeOutput)
);
txSkeleton = txSkeleton.update("cellDeps", (cellDeps) =>
cellDeps.push({
outPoint: {
txHash: lumosConfig.SCRIPTS.SECP256K1_BLAKE160.TX_HASH,
index: lumosConfig.SCRIPTS.SECP256K1_BLAKE160.INDEX,
},
depType: lumosConfig.SCRIPTS.SECP256K1_BLAKE160.DEP_TYPE,
})
);
Next, update specific witness data in the transaction. The witness serves as a place to input data such as signatures for the transaction to be verified on the blockchain. The format of the witness data is flexible; however, in this instance, we adhere to the WitnessArgs specification for basic transaction structure. It is important to note that this specification may evolve to reflect best practices.
The witnessArgs
consists of 3 distinct parts, each corresponding to the different data required for the execution of specific scripts:
export interface WitnessArgs {
lock?: HexString; // lock scripts of the input Cells
inputType?: HexString; // type scripts of the input Cells
outputType?: HexString; // type scripts of the output Cells
}
We update the witness part according to the transaction structure.
const firstIndex = txSkeleton
.get("inputs")
.findIndex((input) =>
bytes.equal(
blockchain.Script.pack(input.cellOutput.lock),
blockchain.Script.pack(fromScript)
)
);
if (firstIndex !== -1) {
while (firstIndex >= txSkeleton.get("witnesses").size) {
txSkeleton = txSkeleton.update("witnesses", (witnesses) =>
witnesses.push("0x")
);
}
let witness: string = txSkeleton.get("witnesses").get(firstIndex)!;
const newWitnessArgs: WitnessArgs = {
/* 65-byte zeros in hex */
lock: "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
};
if (witness !== "0x") {
const witnessArgs = blockchain.WitnessArgs.unpack(bytes.bytify(witness));
const lock = witnessArgs.lock;
if (
!!lock &&
!!newWitnessArgs.lock &&
!bytes.equal(lock, newWitnessArgs.lock)
) {
throw new Error(
"Lock field in first witness is set aside for signature!"
);
}
const inputType = witnessArgs.inputType;
if (!!inputType) {
newWitnessArgs.inputType = inputType;
}
const outputType = witnessArgs.outputType;
if (!!outputType) {
newWitnessArgs.outputType = outputType;
}
}
witness = bytes.hexify(blockchain.WitnessArgs.pack(newWitnessArgs));
txSkeleton = txSkeleton.update("witnesses", (witnesses) =>
witnesses.set(firstIndex, witness)
);
}
Next, we need to sign the transaction. But before that we will create a signing message.
- Generate signingEntries based on the transaction's Inputs and Outputs
- Retrieve the signature message
- Use the private key to sign the message recoverably, including the signature information and necessary metadata for subsequent signature verification processes
txSkeleton = commons.common.prepareSigningEntries(txSkeleton);
const message = txSkeleton.get("signingEntries").get(0)?.message;
const Sig = hd.key.signRecoverable(message!, options.privKey);
Now let's seal our transaction with the txSkeleton
and the just-generated signature
const tx = helpers.sealTransaction(txSkeleton, [Sig]);
Send the transaction
const hash = await rpc.sendTransaction(tx, "passthrough");
You can open the console on the browser to see the full transaction to confirm the process.
Congratulations!
By following this tutorial this far, you have mastered how transfer balance works on CKB. Here's a quick recap:
- Capacity of the Cell means how much CKB balance you have and how much data can be stored in this Cell at the same time
- To build a CKB transaction is just to collecting some Live Cells and producing some new Cells.
- We follow the
witnessArgs
to place the needed signature or any other data in the transaction.
Next Step
So now your app works great on the local blockchain, you might want to switch it to different environments like Testnet and Mainnet.
To do this, you need to update the chain config and related code.
Open the ckb.ts
in your project root dir, change the lumosConfig
and CKB_RPC_URL
:
//export const lumosConfig: config.Config = devConfig as config.Config;
export const lumosConfig = config.predefined.AGGRON4 as config.Config;
//export const CKB_RPC_URL = 'http://localhost:8114';
export const CKB_RPC_URL = "https://testnet.ckb.dev/rpc";
Actually, we have the corresponding Testnet version examples for all these tutorials. The source code of the Testnet version is in https://github.com/nervosnetwork/docs.nervos.org/tree/develop/examples, you can clone the repo and start running on Testnet.
git clone https://github.com/nervosnetwork/docs.nervos.org.git
cd docs.nervos.org/examples/<example-name>
yarn && yarn start
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