Working with NFT on Nervos Network

Working with NFT on Nervos Network

Using Layer 1 Common Knowledge Base (CKB)

·

11 min read

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:

  1. Generating raw unsigned transactions by API;
  2. Signing raw transaction into the blockchain;
  3. 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.