Skip to main content

View and Transfer a CKB Balance

Tutorial Overview

⏰ Estimated Time: 2 - 5 min
🔧 Tools You Need:

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.

? 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

Step 2: Start the Devnet

To interact with the dApp, you need to have your Devnet running. Open one terminal and start the Devnet:

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: Run the Example

Navigate to your project, install the node dependencies, and start running the example:

cd <project-name> && yarn && yarn start

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.gte(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