Working with NFT on Nervos Network
Using Layer 1 Common Knowledge Base (CKB)
In this post, I am going to describe how to work with NFT on the Nervos Layer 1 CKB blockchain (Common Knowledge Base) as a developer.
On the last hackathon provided by this company, I developed a beta version of Telegram bot the NFT Marketplace called Nerbot and took an honorable prize there.
I will try to focus more on how to use this blockchain for NFT, not on building a Telegram bot. In this project, I used Golang, but you could use any language you like. Provided code snippets were cut from source code and I skipped common business logic. I hope, it will be useful for you. So, let's start!
The easiest way to interact with NFT on Nervos Network is to use an API built by Nervina Labs company. The team developed really powerful and simple-to-use service called Mibao, which allows developers (and as a simple user using UI) to avoid deep dive into blockchain low-level stuff. All you need as usual is to get API KEY to get access. Go to v.mibao.net and click to Apply for API Key on the footer page.
The most common operations with NFTs are getting all tokens by address, transferring, creating, distributing, and getting detailed information. I will not show all operations here, if you're interested more you can read API docs.
The code below demonstrates how to get tokens under the given address.
I used API with this endpoint GET - /api/v1/indexer/holder_tokens/
:
//description: TokenItem object
type TokenItem struct {
TokenId int json:"n_token_id"
Oid string json:"oid"
ClassUuid string json:"class_uuid"
ClassName string json:"class_name"
ClassBgImageUrl string json:"class_bg_image_url"
ClassDescription string json:"class_description"
TokenUuid string json:"token_uuid"
ClassTotal string json:"class_total"
ClassIssued string json:"class_issued"
Likes int json:"class_likes"
Renderer string json:"renderer"
RendererType string json:"renderer_type"
IssuerName string json:"issuer_name"
IssuerAvatar_url string json:"issuer_avatar_url"
State string json:"tx_state"
FromAddress string json:"from_address"
TokenOutpoint TokenOutpoint json:"token_outpoint"
}
type HolderTokens struct {
Tokens []TokenItem json:"token_list"
}
//GetTokensByAddress returns tokens under the given address
func GetTokensByAddress(keyID, secretKey, baseURL, address string) (*HolderTokens, error) {
path := "/api/v1/indexer/holder_tokens/" + address
method := "GET"
contentType := "application/json"
payload := strings.NewReader(``)
client := &http.Client{}
req, err := http.NewRequest(method, baseURL+path, payload)
if err != nil {
return nil, err
}
// Constructing singature
const TimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT"
dateNow := time.Now().UTC().Format(TimeFormat)
signature := method + "\n" + path + "\n\n" + contentType + "\n" + dateNow
hashedSecret := ComputeHmac256(signature, secretKey)
// Add Authorization header
req.Header.Add("Authorization", "NFT "+keyID+":"+hashedSecret)
req.Header.Add("Content-Type", contentType)
req.Header.Add("Date", dateNow)
// Send HTTP request
res, err := client.Do(req)
fmt.Println(res.Status)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != 200 {
body, err := ioutil.ReadAll(res.Body)
if err != nil {
log.Fatalf("%s\nError reading res.Body", err)
return nil, err
}
return nil, fmt.Errorf(res.Status)
}
body, err := ioutil.ReadAll(res.Body)
if err != nil {
log.Fatalf("%s\nError reading res.Body", err)
return nil, err
}
t := &model.HolderTokens{}
err = json.Unmarshal(body, &t)
if err != nil {
log.Fatalf("%s\n Error Unmarshalling response", err)
return nil, err
}
return t, err
}
Function to encode constructed signature:
// Compute HMAC-SHA1
func ComputeHmac256(message string, secret string) string {
key := []byte(secret)
h := hmac.New(sha1.New, key)
h.Write([]byte(message))
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
The most tricky part for me was constructing a signature
.
Reading tutorial on Mibao site (in Chinese), signature consists with a list of parameters encoded by MD5:
Authorization = 'NFT ' + AccessKeyId + ':' + Signature
Signature = base64(hmac-sha1(AccessKeySecret,
VERB + '\n'
+ FULLPATH + '\n'
+ Content-MD5 + '\n'
+ Content-Type + '\n'
+ Date))
VERB represents the method of the request, including GET, POST, PUT, etc. \n Represents a newline character
FULLPATH indicates the requested endpoint, if there is a query string, it also needs to be included
Content-MD5 indicates the MD5 value of the requested content data. Calculate the MD5 value of the message content (excluding the header) to obtain a 128-bit number, and then base64 encode the number to get it. The request header can be used to check the validity of the message (whether the content of the message is the same as when it was sent); when the body is empty, leave the Content-MD5 blank. For details, please refer to RFC2616 Content-MD5
Content-Type Indicates the type of requested content, for example, application/JSON, it can also be empty
Date represents the time of this operation, must GMT format, as Sun, 22 Nov 2015 08:16:38 GMT
Also, important to know that described above in the headers, Date, Content-Type, and Authorization for the field must contain, and the Datetime of the time difference between your server and API server not exceed 10 minutes, or API server rejects the request, and return a 401 error. In calculating the signature, Content-Type and Content-MD5 can be null characters, but can not be omitted.
Let's see an example of the successful response of getting tokens by ckt1q3vvtay34wndv9nckl8hah6fzzcltcqwcrx79apwp2a5lkd07fdxxd7zks02fggrprgy8ausfyarehkzskz6ux4lvkx
address:
{
"holder_address": "ckt1q3vvtay34wndv9nckl8hah6fzzcltcqwcrx79apwp2a5lkd07fdxxd7zks02fggrprgy8ausfyarehkzskz6ux4lvkx",
"meta": {
"total_count": 12,
"current_page": 1
},
"token_list": [
{
"token_uuid": "4f211fd0-b0a1-49c9-8af8-5076e5279731",
"n_token_id": 0,
"class_uuid": "1c9f949b-288d-4d00-a12f-76c162c3002a",
"class_name": "NFT BOT - testing GIF",
"class_bg_image_url": "https://ko.com.ua/files/u5101/tenor-google.GIF",
"renderer_type": "image",
"class_description": "Testing GIF formats",
"class_total": "10",
"class_issued": "3",
"is_class_banned": false,
"tx_state": "committed",
"issuer_name": "SPECULARI",
"issuer_avatar_url": "",
"issuer_uuid": "a4962f6d-805f-4559-8ac7-9eccad8adc73",
"is_issuer_banned": false,
"from_address": "ckt1qjda0cr08m85hc8jlnfp3zer7xulejywt49kt2rr0vthywaa50xwsaxagefjfvt2l5yfq9pcrv700j8e0v7kjcl97c7",
"to_address": "ckt1q3vvtay34wndv9nckl8hah6fzzcltcqwcrx79apwp2a5lkd07fdxxd7zks02fggrprgy8ausfyarehkzskz6ux4lvkx",
"token_outpoint": {
"tx_hash": "0x1174a643bd963447bb824894d6edd99229033ea4a751bdebe88979b6805f103d",
"index": 0
},
"verified_info": {
"is_verified": null,
"verified_title": "",
"verified_source": ""
},
"class_likes": 0,
"card_back_content_exist": false,
"configure": 192,
"n_state": 0,
"characteristic": "0000000000000000"
},
]
}
As we see, each NFT token has a lot of fields. Most important are class_uuid
, token_uuid
, and `n_token_id. It's very important to differentiate because for the first time I was confused. Let's me explain by an example using testnet NFT explorer what all these fields mean:
ckb-nft-explorer.staging.nervina.cn/nft/6fa..
/6fa4f5f5-fca6-4d96-93e6-427c14b5e47c - this is class_uuid
;
#0, #1, #2 - issued number of class class_uuid
, starts from #0 index, this is n_token_id
;
token_uuid is hidden in the frontend, so to see it you need to click by any token id, and then open browser dev tools, and in response, you can find UUID
for the current NFT. This UUID is used to identify NFT you want to transfer and get additional information.
For example, Token with #0 has token_uuid
: "900cf3f4-a6f6-429f-a806-92535cc7d412", class_uuid
: "6fa4f5f5-fca6-4d96-93e6-427c14b5e47c", and n_token_id
: "0".
Ok, hope it's clear. Let's move forward.
Most interesting and a little more complex is transferring NFT. This process consists of 3 steps:
- Generating raw unsigned transactions by API;
- Signing raw transaction into the blockchain;
- Sending signed transaction to API.
Step 1 - generating unsigned transactions using GET - /tx/token_transfers/new
endpoint:
// Model of response for GET /tx/token_transfers/new
type lock struct {
CodeHash string json:"code_hash"
Args string json:"args"
HashType string json:"hash_type"
}
type typeField struct {
CodeHash string json:"code_hash"
Args string json:"args"
HashType string json:"hash_type"
}
type cellDepp struct {
CellDepps outPoint json:"out_point"
DefType string json:"dep_type"
}
type outPoint struct {
TxHash string json:"tx_hash"
Index string json:"index"
}
// inputs:[]
type previousOutput struct {
TxHash string json:"tx_hash"
Index string json:"index"
}
type inputs struct {
PreviousOutput previousOutput json:"previous_output"
Since string json:"since"
Capacity string json:"capacity"
Lock lock json:"lock"
Type typeField json:"type"
}
// outputs:[]
type outputs struct {
Capacity string json:"capacity"
Lock lock json:"lock"
Type typeField json:"type"
}
// Unsigned tx
type unsignedTxFields struct {
Version string json:"version"
CellDeps []cellDepp json:"cell_deps"
HeaderDeps []string json:"header_deps"
Inputs []inputs json:"inputs"
Outputs []outputs json:"outputs"
OutputsData []string json:"outputs_data"
Witnesses []string json:"witnesses"
}
type UnsignedTx struct {
Data unsignedTxFields json:"unsigned_tx"
}
// GenerateTokenTransferUnsignedTx get the generated unsigned token transfer transaction by the given uuid
func GenerateTokenTransferUnsignedTx(baseURL, keyID, secretKey, fromAddress, toAddress, tokenUUID string) (*UnsignedTx, string, error) {
path := "/api/v1/tx/token_transfers/new"
method := "GET"
contentType := "application/json"
payload := strings.NewReader(``)
client := &http.Client{}
req, err := http.NewRequest(method, baseURL+path, payload)
if err != nil {
fmt.Println(err)
return nil, "", err
}
// Header params
q := url.Values{}
q.Add("from_address", fromAddress)
q.Add("to_address", toAddress)
q.Add("token_uuid", tokenUUID)
req.URL.RawQuery = q.Encode()
const TimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT"
dateNow := time.Now().UTC().Format(TimeFormat)
signature := method + "\n" + path + "?" + q.Encode() + "\n\n" + contentType + "\n" + dateNow
hashedSecret := ComputeHmac256(signature, secretKey)
// Add Authorization header
req.Header.Add("Authorization", "NFT "+keyID+":"+hashedSecret)
req.Header.Add("Content-Type", contentType)
req.Header.Add("Date", dateNow)
// Send HTTP request
res, err := client.Do(req)
fmt.Println(res.Status)
if err != nil {
fmt.Println(err)
return nil, "", err
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
log.Fatalf("%s\nerror reading res.Body", err)
return nil, "", err
}
unTx := &model.UnsignedTx{}
errResp := &model.ErrorResp{}
// Separate status codes
switch res.StatusCode {
case 200:
err = json.Unmarshal(body, &unTx)
if err != nil {
log.Fatalf("%s\n Error Unmarshalling response", err)
return nil, "", err
}
fmt.Printf("\nUnmarshalled response:%+v\n", unTx.Data.Inputs[0].PreviousOutput)
return unTx, string(body), err
case 400:
err = json.Unmarshal(body, &errResp)
if err != nil {
log.Fatalf("%s\nerror Unmarshalling response", err)
return nil, "", err
}
if errResp.Message == "token is under transferring" {
return nil, string(body), model.ErrTokenUnderTransfer
}
return nil, string(body), fmt.Errorf("unknown error: %s", errResp.Message)
case 404:
err = json.Unmarshal(body, &errResp)
if err != nil {
log.Fatalf("%s\nerror Unmarshalling response", err)
return nil, "", err
}
return nil, string(body), model.ErrTokenNotFound
default:
return nil, string(body), fmt.Errorf("unknown error: %s", res.Status)
}
}
A successful response returns generated unsigned transaction
that we'll use in Step 2.
Step 2 - signing this transaction into the blockchain using SDK
To make life easier working with blockchain, the Nervos team developed SDKs for the most popular languages. In my case, I am using Go-SDK to sign generated transaction from Step 1:
type SignedTx struct {
Outputs []outputs json:"outputs"
}
type TransactionSucceed struct {
TxHash string json:"tx_hash"
TxUUID string json:"tx_uuid"
TokenUUID string json:"token_uuid"
}
type RequestBodyTransfer struct {
FromAddress string json:"from_address"
ToAddress string json:"to_address"
TokenUuid string json:"token_uuid"
NftTypeArgs string json:"nft_type_args"
SignedTx string json:"signed_tx"
}
func SignTxString(unTx *UnsignedTx, privateKey, baseURL, keyID, secretKey, fromAddress, toAddress, tokenUUID string) (*TransactionSucceed, error) {
rawTx, err := json.Marshal(unTx.Data)
fmt.Println("rawTx:", string(rawTx))
if err != nil {
fmt.Println("Error marshaling UnsignedTx", err)
return nil, err
}
// 1. parse string tx to Transaction object
ckbTx, err := rpc.TransactionFromString(string(rawTx))
if err != nil {
fmt.Println(err)
return nil, err
}
key, err := secp256k1.HexToKey(privateKey)
if err != nil {
fmt.Println("HexToKey Error:", err)
return nil, err
}
// 2. sign the tx using rpc
err = transaction.SingleSegmentSignTransaction(ckbTx, 0, len(ckbTx.Witnesses), transaction.EmptyWitnessArg, key)
if err != nil {
fmt.Println("Single Segment Sign Transaction Error:", err)
return nil, err
}
signedTxStr, err := rpc.TransactionString(ckbTx)
if err != nil {
fmt.Println("TransactionStringError:", err)
return nil, err
}
// 3. send the signed tx to saas api
fmt.Println("=========SIGNED TRANSACTION=========")
fmt.Println("signedTxStr:", signedTxStr)
// Send signed tx to API
return sendSignedTransaction(baseURL, keyID, secretKey, signedTxStr, fromAddress, toAddress, tokenUUID)
}
Step 3 - sending signed transaction to API
Here we're sending signed transaction
using POST - /tx/token_transfers
endpoint and thereby transferring token:
func sendSignedTransaction(baseURL, keyID, secretKey, rawSignedTx, fromAddress, toAddress, tokenUUID string) (*TransactionSucceed, error) {
path := "/api/v1/tx/token_transfers"
method := "POST"
contentType := "application/json"
// Model SignedTx
modelSignedTx := &SignedTx{}
err := json.Unmarshal([]byte(rawSignedTx), &modelSignedTx)
if err != nil {
fmt.Println("Error unmarshal SignedTx to model", err)
return nil, err
}
// Constructing payload
payload := &RequestBodyTransfer{
FromAddress: fromAddress,
ToAddress: toAddress,
TokenUuid: tokenUUID,
NftTypeArgs: modelSignedTx.Outputs[0].Type.Args,
SignedTx: rawSignedTx,
}
postBody, _ := json.Marshal(payload)
responseBody := bytes.NewBuffer(postBody)
// Encoding MD5
md5 := md5.New()
md5.Write(postBody)
contentMD5 := base64.StdEncoding.EncodeToString(md5.Sum(nil))
client := &http.Client{}
req, err := http.NewRequest(method, baseURL+path, responseBody)
if err != nil {
return nil, err
}
// Constructing signature
const TimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT"
dateNow := time.Now().UTC().Format(TimeFormat)
signature := method + "\n" + path + "\n" + contentMD5 + "\n" + contentType + "\n" + dateNow
hashedSecret := api.ComputeHmac256(signature, secretKey)
// Add Authorization header
req.Header.Add("Authorization", "NFT "+keyID+":"+hashedSecret)
req.Header.Add("Content-Type", contentType)
req.Header.Add("Date", dateNow)
// Send HTTP request
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
log.Fatalf("%s\nError reading res.Body", err)
return nil, err
}
if res.StatusCode != 200 {
errBody := &dbModel.Error{}
json.Unmarshal(body, &errBody)
return nil, fmt.Errorf("\nerror response message: \nstatus code: %v\nmessage: %s\ndetails: %v", res.StatusCode, errBody.Message, errBody.Detail)
}
// Model TransactionSucceed
trSuccced := &TransactionSucceed{}
err = json.Unmarshal(body, &trSuccced)
if err != nil {
log.Fatalf("%s\nerror Unmarshalling response", err)
return nil, err
}
fmt.Printf("\nSucceed response:%+v\n", trSuccced)
return trSuccced, err
}
As a response API returns tx_hash
, tx_uuid
, and token_uuid
.
tx_hash
you can track using the main CKB explorer ;
tx_uuid
tx hash analog used in NFT explorer ;
token_uuid
is transferred NFT token.
Additional information
Storing data into Layer 1 of the CKB blockchain is not free. One NFT costs 133 $CKB. 1 $CKB token on Binance trades for $0.0230 (at time of post writing). But Nervina Labs team is working on a new type of NFT - m-NFT that will be much cheaper on blockchain storage.
Summary
Summary, few words about my experience using Nervos blockchain. Of course, using Mibao API for interacting with NFT makes developer life much easier. But nobody disallows you to use some SDK for your favorite language and to try to interact with blockchain on a bit lower level. Also, you can use Rust language for developing your own smart contracts for Layer 1. But recently Nervos launched Layer 2 called Godwoken. It's an EVM compatible layer 2 built on Nervos Layer 1. Maybe, I will write an additional post on how to use it. Stay tuned.
Useful links:
API Documentation: app.swaggerhub.com/apis/ShiningRay/NftSaasO..
The Nerbot - twitter.com/TheNerbot
Testnet Mibao: staging.nervina.cn
Testnet NFT wallet: wallet.staging.nervina.cn
Testnet NFT explorer: ckb-nft-explorer.staging.nervina.cn
User guide on how to use Mibao for non-developers (official, might be outdated): docs.google.com/document/d/1fYsopNgx_vg2V6f..
Best practice from the team (Chinese): hackmd.io/@ybian/B1AdNn6dF
Mainet Mibao: v.mibao.net
Nervos official website: nervos.org
CKB explorer: explorer.nervos.org
Nervos Github: github.com/nervosnetwork