Tutorial
In this tutorial, we will develop a simple HD wallet to help manage assets on CKB. And we need to implement some logic that most wallets have
- Generate private key and address
- Check CKB balance
- Transfer CKB to other address
This simple example can help to understand how to develop CKB dapp with Lumos
.
1. Create a new Lumos project
We'll install Lumos using the Node.js package manager (npm), which is both a package manager and an online repository for JavaScript code.
You can get help if you don't have Node.js,
You can use other package managers in Node.js, but we recommend to use npm to follow this tutorial.
Open a new terminal and run these commands to create a new folder:
mkdir lumos-tutorial
cd lumos-tutorial
Then initialize an npm project as shown below. You will be prompted to answer some questions.
Use the tabs in the snippet to select your preferred package manager.
- npm
- yarn
npm init
yarn init
Now we can install Lumos
- npm
- yarn
npm install @ckb-lumos/lumos
yarn add @ckb-lumos/lumos
2. Generate a wallet account
For a wallet, the most important and commonly used function is view account. This tutorial generates private key and address by @ckb-lumos/hd
If you don't know much about HD Wallet, this article has a very good explanation
Create the wallet.ts
file in the root directory
// wallet.ts
import { hd } from '@ckb-lumos/lumos';
const { mnemonic, ExtendedPrivateKey } = hd;
export const generateFirstHDPrivateKey = () => {
const myMnemonic = mnemonic.generateMnemonic();
const seed = mnemonic.mnemonicToSeedSync(myMnemonic);
console.log("my mnemonic ", seed);
const extendedPrivKey = ExtendedPrivateKey.fromSeed(seed);
return extendedPrivKey.privateKeyInfo(AddressType.Receiving, 0).privateKey;
}
CKB has the testnet Arggon
and the mainnet Lina
, Lumos
can work on different environment.
lumos has a config
module to help us manage the config of the different envrioment
import { config } from '@ckb-lumos/lumos';
// this tutorial will use the testnet.
config.initializeConfig(config.predefined.AGGRON4);
After setting up the environment, we need to implement a generate address function
import { hd, config, helpers } from '@ckb-lumos/lumos';
const getAddressByPrivateKey = (privateKey: string) => {
const args = hd.key.privateKeyToBlake160(privateKey);
const template = config.predefined.AGGRON4.SCRIPTS["SECP256K1_BLAKE160"]!;
const lockScript = {
codeHash: template.CODE_HASH,
hashType: template.HASH_TYPE,
args: args,
};
return helpers.encodeToAddress(lockScript);
}
Now we can try to run this code
When you generate a private key, keep it in a safe place and do not disclose it to anyone, otherwise the asset may not be unlocked or lost.
const privateKey = generateFirstHDPrivateKey()
const address = getAddressByPrivateKey(privateKey);
console.log('privateKey: ', privateKey)
console.log('address: ', address)
Maybe you'll get an output like this, save the privateKey and keep it in a safe place
// it's a sample print, dont use this sample account directly.
privateKey: 0x9ab62c912c48d615a030318af514758e8c7b9f03d37a95dd1b89e775b669e0c3
address: ckb1qzda0cr08m85hc8jlnfp3zer7xulejywt49kt2rr0vthywaa50xwsq0820lwy5m5uqnhpap2h0kms9ta3u3pp2ss889v4
3. Check CKB Balance
In the previous section we generated account, but for a wallet it's not enough, we need to know how much CKB balance we have
CKB provides a rpc for searching on-chain data, but many on-chain data are very troublesome to index. so CKB also provides an indexer rpc to help searching easily.
You can view the public rpc of the testnet and mainnet here
import { RPC, Indexer } from '@ckb-lumos/lumos';
const CKB_RPC_URL = "https://testnet.ckb.dev/rpc";
const CKB_INDEXER_URL = "https://testnet.ckb.dev/indexer";
const rpc = new RPC(CKB_RPC_URL);
const indexer = new Indexer(CKB_INDEXER_URL, CKB_RPC_URL);
In CKB, UTXO is called Cell
, and the CKB balance as the capacity field of Cell
, when we want to know how much balance we have, we need to find all our Cells
import { BI, helpers } from '@ckb-lumos/lumos';
export async function getCapacities(address: string): Promise<BI> {
const collector = indexer.collector({
lock: helpers.parseAddress(address),
});
let capacities = BI.from(0);
for await (const cell of collector.collect()) {
capacities = capacities.add(cell.cellOutput.capacity);
}
return capacities;
}
Congratulations! we write a function to get the balance of account, now we can call it to get our balances
The unit of capacity in CKB is Shannon, 1CKB = 10^8 Shannon, so here we have to divide by 10^8 to get the unit of CKB
// write this at the end of previous wallet.ts file
console.log(`address: ${address}`)
getCapacities(address).then(capacities => console.log(`balance: ${capacities.div(10 ** 8).toString()} CKB`))
can use npx ts-node wallet.ts
command to run it
Maybe you'll get an output like this
address: <your-address>
balance: 0 CKB
4. Claim CKB from the test net faucet
It looks like we don't have any CKB yet, don't feel bad, faucet can give us some CKB, click here to access
Input address to the middle input box and click the Claim button, then you will see an additional data in the page
Waiting about 10s, the status of this data will change from Pending to Processed, so let's run wallet again and check balance
address: <your-address>
balance: 10000 CKB
5. Create a transfer transaction
Suppose bob is a pizzeria and you have ordered a pizza from bob with 100 CKB and now need to pay bob for the 100 CKB, bob tells you his address is: ckt1qzda0cr08m85hc8jlnfp3zer7xulejywt49kt2rr0vthywaa50xwsqgy5rtexzvhk7jt7gla8wlq5lztf79tjhg9fmd4f
We can upgrade the wallet to support transfer
@ckb-lumos
provides some common methods to help build this transaction
import { helpers, commons } from '@ckb-lumos/lumos';
let txSkeleton = helpers.TransactionSkeleton({ cellProvider: indexer });
txSkeleton = await commons.common.transfer(
txSkeleton,
['<your-address>'],
'ckt1qzda0cr08m85hc8jlnfp3zer7xulejywt49kt2rr0vthywaa50xwsqgy5rtexzvhk7jt7gla8wlq5lztf79tjhg9fmd4f',
BigInt(100 * 10 ** 8),
);
Now that we have build a base transaction, but in order for this transaction to be successfully sent to the chain, we need to pay some fees to the miner.
Fee are calculated by the size of the transaction. And lumos provides the payFeeByFeeRate
method to help pay fee
import { helpers, commons } from '@ckb-lumos/lumos';
txSkeleton = await commons.common.payFeeByFeeRate(
txSkeleton,
['<your-address>'],
1000,
);
Finally we need to sign the transaction
import { BI, hd, config, helpers, RPC, Indexer, commons } from '@ckb-lumos/lumos';
txSkeleton = commons.common.prepareSigningEntries(txSkeleton);
const message = txSkeleton.get("signingEntries").get(0)?.message;
const Sig = hd.key.signRecoverable(message!, privateKey);
const tx = helpers.sealTransaction(txSkeleton, [Sig]);
const txHash = await rpc.sendTransaction(tx, "passthrough");
console.log(`txHash is: ${txHash}`)
Let's run the transfer function and we'll see an output like txHash is: 0x998dd1ec986ad0f1aff5f527dc3e9fe13cba0d20ee2e7ee0ce83af213cd399c4
, let's go to block explorer to look up the transaction
We can see this transaction did consume a 10000CKB cell and generated a 100CKB cell for bob as well as generated change for itself
Congratulations, you can now lie on the couch waiting to enjoy a delicious pizza!
Complete sample code, click to expand
import { BI, hd, config, helpers, RPC, Indexer, commons, Address, HexString } from '@ckb-lumos/lumos';
const { mnemonic, ExtendedPrivateKey } = hd;
config.initializeConfig(config.predefined.AGGRON4);
const CKB_RPC_URL = "https://testnet.ckb.dev/rpc";
const CKB_INDEXER_URL = "https://testnet.ckb.dev/indexer";
const rpc = new RPC(CKB_RPC_URL);
const indexer = new Indexer(CKB_INDEXER_URL, CKB_RPC_URL);
export const generateFirstHDPrivateKey = () => {
const m = mnemonic.generateMnemonic()
const seed = mnemonic.mnemonicToSeedSync(m)
console.log('my mnemonic ', seed);
const extendedPrivKey = ExtendedPrivateKey.fromSeed(seed);
return extendedPrivKey.privateKeyInfo(
AddressType.Receving,
0,
).privateKey
}
const getAddressByPrivateKey = (privateKey: string) => {
const args = hd.key.privateKeyToBlake160(privateKey);
const template = config.predefined.AGGRON4.SCRIPTS["SECP256K1_BLAKE160"]!;
const lockScript = {
codeHash: template.CODE_HASH,
hashType: template.HASH_TYPE,
args: args,
};
return helpers.encodeToAddress(lockScript);
}
export async function getCapacities(address: string): Promise<BI> {
const collector = indexer.collector({
lock: helpers.parseAddress(address),
});
let capacities = BI.from(0);
for await (const cell of collector.collect()) {
capacities = capacities.add(cell.cellOutput.capacity);
}
return capacities;
}
const transfer = async(from: Address, to: Address, capacity: number, privateKey: HexString) => {
let txSkeleton = helpers.TransactionSkeleton({ cellProvider: indexer });
txSkeleton = await commons.common.transfer(
txSkeleton,
[from],
to,
BigInt(capacity),
);
txSkeleton = await commons.common.payFeeByFeeRate(
txSkeleton,
[from],
1000,
);
txSkeleton = commons.common.prepareSigningEntries(txSkeleton);
const message = txSkeleton.get("signingEntries").get(0)?.message;
const Sig = hd.key.signRecoverable(message!, privateKey);
const tx = helpers.sealTransaction(txSkeleton, [Sig]);
return rpc.sendTransaction(tx, "passthrough");
}
// input your privateKey or generate a new privateKey
const privateKey = `0x9ab62c912c48d615a030318af514758e8c7b9f03d37a95dd1b89e775b669e0c3`
const address = getAddressByPrivateKey(privateKey);
const bobAddress = 'ckt1qzda0cr08m85hc8jlnfp3zer7xulejywt49kt2rr0vthywaa50xwsqgy5rtexzvhk7jt7gla8wlq5lztf79tjhg9fmd4f'
console.log('address: ', address)
getCapacities(address).then(capacities => console.log(`balance: ${capacities.div(10 ** 8).toString()} CKB`))
transfer(address, bobAddress, 100 * 10 ** 8, privateKey).then((txHash: string) => console.log('txHash: ', txHash));
Advance: Upgrade wallet for support Token (like ERC20)
In the last section we created a simple wallet to view CKB balance and transfer CKB, in this section we will upgrade the wallet to support Token viewing and transfer, here are some of the features to be implemented
- Issue a new Token
- Transfer Token to Alice
- Check Alice's Token balance
Token is a similar to ERC20 in the ethereum ecosystem. The token in CKB is called SUDT (Simple User Defined Token), and the data structure for storing SUDT cell looks like this.
capacity:
ckb amount
data:
udt amount (uint128)
type:
code_hash:
sudt code hash
args:
issuer lock hash
lock:
user defined
Issue a new Token
Run the following command to create a new file.
touch sudt-wallet.ts
First set up the lumos
import { config } from '@ckb-lumos/lumos';
config.initializeConfig(config.predefined.AGGRON4);
const CKB_RPC_URL = "https://testnet.ckb.dev/rpc";
const CKB_INDEXER_URL = "https://testnet.ckb.dev/indexer";
const rpc = new RPC(CKB_RPC_URL);
const indexer = new Indexer(CKB_INDEXER_URL, CKB_RPC_URL);
@ckb-lumos/common-scripts
provides some simple functions to help us build an issue transaction
const issueToken = async (issuer: Address, amount: number, privateKey: string) => {
let txSkeleton = helpers.TransactionSkeleton({ cellProvider: indexer });
txSkeleton = await commons.sudt.issueToken(
txSkeleton,
issuer,
amount,
);
txSkeleton = await commons.common.payFeeByFeeRate(
txSkeleton,
[issuer],
1000,
);
txSkeleton = commons.common.prepareSigningEntries(txSkeleton);
const message = txSkeleton.get("signingEntries").get(0)?.message;
const Sig = hd.key.signRecoverable(message!, privateKey);
const tx = helpers.sealTransaction(txSkeleton, [Sig]);
return rpc.sendTransaction(tx, "passthrough");
}
Let's add a few logs and run the code
const privateKey = '0x9ab62c912c48d615a030318af514758e8c7b9f03d37a95dd1b89e775b669e0c3';
issueToken(getAddressByPrivateKey(privateKey), 1000, privateKey).then((txHash: string) => console.log('txHash: ', txHash));
We can get an output like this, search it in explorer and indeed find SUDT have issued
txHash: 0x53dbf57c44938bc95cb3e33a0ac10ce5c3c37e88224ea2e785a6499ac4ca1c4d
Transfer Token to Alice
Suppose the alice's address is ckt1qzda0cr08m85hc8jlnfp3zer7xulejywt49kt2rr0vthywaa50xwsq2x0z0rmc44ek25rmdk7dky5wnhdlrmqncyhcvkp
@ckb-lumos/common-scripts
also provides some simple functions to help transfer SUDT
export const transferToken = async (issuer: Address, to: Address, amount: number, privateKey: string) => {
let txSkeleton = helpers.TransactionSkeleton({ cellProvider: indexer });
const scriptLockHash = utils.computeScriptHash(helpers.parseAddress(issuer))
txSkeleton = await commons.sudt.transfer(
txSkeleton,
[issuer],
scriptLockHash,
to,
BI.from(amount),
)
txSkeleton = await commons.common.payFeeByFeeRate(
txSkeleton,
[issuer],
1000,
);
txSkeleton = commons.common.prepareSigningEntries(txSkeleton);
const message = txSkeleton.get("signingEntries").get(0)?.message;
const Sig = hd.key.signRecoverable(message!, privateKey);
const tx = helpers.sealTransaction(txSkeleton, [Sig]);
return rpc.sendTransaction(tx, "passthrough");
}
run this code
// change to yours privateKey
const privateKey = '0x9ab62c912c48d615a030318af514758e8c7b9f03d37a95dd1b89e775b669e0c3';
const aliceAddress = 'ckt1qzda0cr08m85hc8jlnfp3zer7xulejywt49kt2rr0vthywaa50xwsq2x0z0rmc44ek25rmdk7dky5wnhdlrmqncyhcvkp';
transferToken(getAddressByPrivateKey(privateKey), aliceAddress, 10, privateKey).then((txHash: string) => console.log('txHash: ', txHash));
We can get an output like this, search txHash in explorer, and indeed transferring 10 SUDT to alice
txHash: 0xbccac791e1eb4e005d0f8208c9cb3178af046f814fa1c01c2b5f3f6a966c3486
Check Alice's Token balance
Getting Token amount is also finding Cells, but unlike CKB capacities, each SUDT has a type script, and number of Token is stored in the data field, not the capacity field
export const getTokenAmount = async (address: Address, sudtArgs: string) => {
const collector = indexer.collector({
lock: helpers.parseAddress(address),
type: {
codeHash: config.predefined.AGGRON4.SCRIPTS['SUDT'].CODE_HASH,
hashType: config.predefined.AGGRON4.SCRIPTS['SUDT'].HASH_TYPE,
args: sudtArgs,
}
});
let amount = BI.from(0);
for await (const cell of collector.collect()) {
amount = amount.add(number.Uint128LE.unpack(cell.data));
}
return amount;
}
Let's run it and see how many SUDTs alice has
The args of SUDT are the lock script hash of the token issuer, and we can use the built-in utils library in Lumos
to help us calculate the hash
import { utils } from '@ckb-lumos/lumos';
const privateKey = '0x9ab62c912c48d615a030318af514758e8c7b9f03d37a95dd1b89e775b669e0c3';
const issuerAddress = getAddressByPrivateKey(privateKey);
const aliceAddress = 'ckt1qzda0cr08m85hc8jlnfp3zer7xulejywt49kt2rr0vthywaa50xwsq2x0z0rmc44ek25rmdk7dky5wnhdlrmqncyhcvkp';
const scriptLockHash = utils.computeScriptHash(helpers.parseAddress(issuerAddress))
getTokenAmount(aliceAddress, scriptLockHash).then((v) => {console.log('SUDT balance:', v.toString())})
We can get an output like this, which is the 10 SUDT we just transferred to alice
SUDT balance: 10
So we have successfully added the functions of issue SUDT / transfer SUDT / check SUDT balance to the wallet
Complete sample code, click to expand
import { config, Address, RPC, Indexer, helpers, commons, hd, BI, utils } from '@ckb-lumos/lumos';
import { number } from "@ckb-lumos/codec";
config.initializeConfig(config.predefined.AGGRON4);
const CKB_RPC_URL = "https://testnet.ckb.dev/rpc";
const CKB_INDEXER_URL = "https://testnet.ckb.dev/indexer";
const rpc = new RPC(CKB_RPC_URL);
const indexer = new Indexer(CKB_INDEXER_URL, CKB_RPC_URL);
export const issueToken = async (issuer: Address, amount: number, privateKey: string) => {
let txSkeleton = helpers.TransactionSkeleton({ cellProvider: indexer });
txSkeleton = await commons.sudt.issueToken(
txSkeleton,
issuer,
amount,
);
txSkeleton = await commons.common.payFeeByFeeRate(
txSkeleton,
[issuer],
1000,
);
txSkeleton = commons.common.prepareSigningEntries(txSkeleton);
const message = txSkeleton.get("signingEntries").get(0)?.message;
const Sig = hd.key.signRecoverable(message!, privateKey);
const tx = helpers.sealTransaction(txSkeleton, [Sig]);
return rpc.sendTransaction(tx, "passthrough");
}
export const transferToken = async (issuer: Address, to: Address, amount: number, privateKey: string) => {
let txSkeleton = helpers.TransactionSkeleton({ cellProvider: indexer });
const scriptLockHash = utils.computeScriptHash(helpers.parseAddress(issuer))
txSkeleton = await commons.sudt.transfer(
txSkeleton,
[issuer],
scriptLockHash,
to,
BI.from(amount),
)
txSkeleton = await commons.common.payFeeByFeeRate(
txSkeleton,
[issuer],
1000,
);
txSkeleton = commons.common.prepareSigningEntries(txSkeleton);
const message = txSkeleton.get("signingEntries").get(0)?.message;
const Sig = hd.key.signRecoverable(message!, privateKey);
const tx = helpers.sealTransaction(txSkeleton, [Sig]);
return rpc.sendTransaction(tx, "passthrough");
}
export const getTokenAmount = async (address: Address, sudtArgs: string) => {
const collector = indexer.collector({
lock: helpers.parseAddress(address),
type: {
codeHash: config.predefined.AGGRON4.SCRIPTS['SUDT'].CODE_HASH,
hashType: config.predefined.AGGRON4.SCRIPTS['SUDT'].HASH_TYPE,
args: sudtArgs,
}
});
let amount = BI.from(0);
for await (const cell of collector.collect()) {
amount = amount.add(number.Uint128LE.unpack(cell.data));
}
return amount;
}