How to Write a Soulbound Token Smart Contract in Go with KALP SDK



This content originally appeared on DEV Community and was authored by Asjad Ahmed Khan

1. Introduction: What’s a Soulbound Token & Why Use KALP SDK?

Soulbound Tokens (SBTs) are a special type of blockchain token that are non-transferable. Once issued to a wallet, they are permanently bound to it, hence the name “soulbound.”

This makes them ideal for representing credentials, certifications, identity proofs, or memberships that shouldn’t be sold or traded.

Common SBT use cases:

  • Identity badges: Verifiable proof of identity without exposing sensitive details.
  • Certificates & diplomas: Issued by universities or training institutes.
  • Membership passes: For DAOs, communities, or gated services.
  • Reputation markers: Recording trust scores in decentralised marketplaces.

The KALP SDK makes building SBTs in Go straightforward by:

  • Providing a contract framework with built-in blockchain state handling.
  • Offering a familiar Go-based development environment.
  • Integrating easily with KALP Studio, so you can write, deploy, and interact with your smart contracts quickly.

Before diving further into the smart contract structure, let’s first look at the prerequisites.

2. Project Setup

Before we start coding, make sure you have:

Prerequisites:

  • Go installed (Please note: The SDK is compatible with Go version 1.20. For newer versions of Go, update your go.mod file to specify version 1.20).
  • Basic knowledge of Go (structs, functions, methods).
  • A KALP Studio account with console access.
  • Basic understanding of blockchain contracts.

Step 1 – Create a project folder:

mkdir sbt-contract
cd sbt-contract

Step 2 – Initialise Go module:

go mod init sbt-contract

Step 3 – Install KALP SDK:

go get -u github.com/p2eengineering/kalp-sdk-public/kalpsdk

This command utilises the go get package management tool within Go to download and install the Kalp SDK from the specified GitHub repository. The -u flag ensures you receive the latest available version.

Folder Structure:

sbt-contract/
├── main.go
├── go.mod
└── go.sum

The KALP SDK basically works like a smart contract framework. It handles state persistence, event logging, and blockchain interfacing so you can focus on business logic.

3. Understanding the Data Structures

To represent an SBT, we’ll define a few core structs.

SBTMetadata

type SBTMetadata struct {
    Name        string
    Organization string
    IssueDate   string
}

Holds descriptive information about the token, useful for diplomas, certifications, etc.

SoulboundToken

type SoulboundToken struct {
    Owner    string
    TokenID  string
    Metadata SBTMetadata
}

Links the token to a wallet address (Owner), uniquely identified by TokenID, and carries metadata.

SmartContract

type SmartContract struct {
    kalp.Contract
}

We embed the Contract type from KALP SDK to access built-in blockchain functions.

State Key Prefixes

const (
    sbtPrefix          = "sbt:"
    ownerMappingPrefix = "owner:"
)

Prefixes help namespace your storage keys, preventing collisions with other contracts or state values.

4. Contract Functions

4.1 Initialize()

Purpose: Sets up the contract metadata and ensures it’s only done once.

Key steps:

  • Checks if "initialized" flag exists.
  • Stores a description in the ledger as JSON.
  • Marks contract as initialized.
func (s *SmartContract) Initialize(sdk kalpsdk.TransactionContextInterface, description string) error {
    initialized, err := sdk.GetState("initialized")
    if initialized != nil {
        return fmt.Errorf("contract is already initialized")
    }
    metadata := SBTMetadata{Description: description}
    metadataJSON, _ := json.Marshal(metadata)
    sdk.PutStateWithoutKYC("metadata", metadataJSON)
    sdk.PutStateWithoutKYC("initialized", []byte("true"))
    return nil
}

4.2 MintSBT()

Purpose: Issues a new Soulbound Token.

Key steps:

  • Generate UUID token ID.
  • Retrieve contract metadata (set during initialization).
  • Check if the address already owns an SBT.
  • Fill in metadata fields (name, org, date).
  • Create and store the SBT.
  • Map owner to token ID.
func (s *SmartContract) MintSBT(sdk kalpsdk.TransactionContextInterface, address string, name string, organization string, dateOfIssue string) error {
    // Generate a unique token ID (UUID)
    tokenID := uuid.New().String()

    // Retrieve metadata for description
    metadataJSON, err := sdk.GetState("metadata")
    if err != nil {
        return fmt.Errorf("failed to retrieve metadata: %v", err)
    }
    if metadataJSON == nil {
        return fmt.Errorf("contract metadata is not set")
    }

    var metadata SBTMetadata
    err = json.Unmarshal(metadataJSON, &metadata)
    if err != nil {
        return fmt.Errorf("failed to unmarshal metadata: %v", err)
    }

    // Check if the address already has an SBT
    mappingKey, err := sdk.CreateCompositeKey(ownerMappingPrefix, []string{address})
    if err != nil {
        return fmt.Errorf("failed to create composite key for owner mapping: %v", err)
    }
    existingTokenID, err := sdk.GetState(mappingKey)
    if err != nil {
        return fmt.Errorf("failed to check existing SBT for owner: %v", err)
    }
    if existingTokenID != nil {
        return fmt.Errorf("owner '%s' already has an SBT", address)
    }

    // Update metadata with additional details during minting
    metadata.Name = name
    metadata.Organization = organization
    metadata.DateOfIssue = dateOfIssue

    // Marshal the updated metadata
    updatedMetadataJSON, err := json.Marshal(metadata)
    if err != nil {
        return fmt.Errorf("failed to marshal updated metadata: %v", err)
    }

    // Create SBT object
    sbt := SoulboundToken{
        Owner:    address,
        TokenID:  tokenID,
        Metadata: string(updatedMetadataJSON),
    }
    sbtJSON, err := json.Marshal(sbt)
    if err != nil {
        return fmt.Errorf("failed to marshal SBT: %v", err)
    }

    // Composite key for the SBT itself
    compositeKey, err := sdk.CreateCompositeKey(sbtPrefix, []string{address, tokenID})
    if err != nil {
        return fmt.Errorf("failed to create composite key: %v", err)
    }

    // Store the SBT using the composite key
    err = sdk.PutStateWithoutKYC(compositeKey, sbtJSON)
    if err != nil {
        return fmt.Errorf("failed to store SBT: %v", err)
    }

    // Update the owner -> tokenID mapping
    err = sdk.PutStateWithoutKYC(mappingKey, []byte(tokenID))
    if err != nil {
        return fmt.Errorf("failed to update owner mapping: %v", err)
    }

    return nil
}

Important: If an address already owns an SBT, the minting fails

4.3 QuerySBT()

Purpose: Fetches an SBT by owner and tokenID.

Logic:

  • Creates a composite key with the prefix + owner + tokenID.
  • Retrieves SBT JSON from state.
  • Converts it into a SoulboundToken object.
func (s *SmartContract) QuerySBT(sdk kalpsdk.TransactionContextInterface, owner string, tokenID string) (*SoulboundToken, error) {
    compositeKey, err := sdk.CreateCompositeKey(sbtPrefix, []string{owner, tokenID})
    if err != nil {
        return nil, fmt.Errorf("failed to create composite key: %v", err)
    }

    sbtJSON, err := sdk.GetState(compositeKey)
    if err != nil {
        return nil, fmt.Errorf("failed to retrieve SBT: %v", err)
    }
    if sbtJSON == nil {
        return nil, fmt.Errorf("SBT with owner '%s' and tokenID '%s' does not exist", owner, tokenID)
    }
    var sbt SoulboundToken
    err = json.Unmarshal(sbtJSON, &sbt)
    if err != nil {
        return nil, err
    }
    return &sbt, nil
}

4.4 TransferSBT()

Purpose: Enforces the soulbound property by blocking transfers.

func (s *SmartContract) TransferSBT(...) error {
    return fmt.Errorf("soulbound tokens are not transferable")
}

4.5 GetSBTByOwner()

Purpose: Retrieve an SBT knowing only the owner’s address.

Steps:

  • Find token ID from owner mapping.
  • Use token ID + owner to get SBT data.
  • Return the full token object.
func (s *SmartContract) GetSBTByOwner(sdk kalpsdk.TransactionContextInterface, owner string) (*SoulboundToken, error) {
    // Construct the mapping key to get the TokenID
    mappingKey, err := sdk.CreateCompositeKey(ownerMappingPrefix, []string{owner})
    if err != nil {
        return nil, fmt.Errorf("failed to create composite key for owner mapping: %v", err)
    }
    tokenIDBytes, err := sdk.GetState(mappingKey)
    if err != nil {
        return nil, fmt.Errorf("failed to retrieve tokenID for owner '%s': %v", owner, err)
    }
    if tokenIDBytes == nil {
        return nil, fmt.Errorf("owner '%s' does not have an SBT", owner)
    }
    tokenID := string(tokenIDBytes)

    // Construct the key for the SBT data
    sbtKey, err := sdk.CreateCompositeKey(sbtPrefix, []string{owner, tokenID})
    if err != nil {
        return nil, fmt.Errorf("failed to create composite key for SBT: %v", err)
    }

    // Fetch SBT JSON data
    sbtJSON, err := sdk.GetState(sbtKey)
    if err != nil {
        return nil, fmt.Errorf("failed to retrieve SBT data: %v", err)
    }
    if sbtJSON == nil {
        return nil, fmt.Errorf("SBT not found for owner '%s' and tokenID '%s'", owner, tokenID)
    }

    // Unmarshal into an SBT object
    var sbt SoulboundToken
    err = json.Unmarshal(sbtJSON, &sbt)
    if err != nil {
        return nil, fmt.Errorf("failed to unmarshal SBT data: %v", err)
    }

    return &sbt, nil
}

4.6 GetAllTokenIDs()

Purpose: List all token IDs issued in the system.

Steps:

  • Query all state entries under ownerMappingPrefix.
  • Collect token IDs from the values.
  • Return as a slice of strings.
func (s *SmartContract) GetAllTokenIDs(sdk kalpsdk.TransactionContextInterface) ([]string, error) {
    // Get all states with the ownerMapping prefix
    iterator, err := sdk.GetStateByPartialCompositeKey(ownerMappingPrefix, []string{})
    if err != nil {
        return nil, fmt.Errorf("failed to get state iterator: %v", err)
    }
    defer iterator.Close()

    var tokenIDs []string
    for iterator.HasNext() {
        response, err := iterator.Next()
        if err != nil {
            return nil, fmt.Errorf("failed to get next state: %v", err)
        }

        // The value stored in the ownerMapping is the tokenID
        tokenID := string(response.Value)
        tokenIDs = append(tokenIDs, tokenID)
    }

    if len(tokenIDs) == 0 {
        return nil, fmt.Errorf("no SBTs found")
    }

    return tokenIDs, nil
}

5. Main Function

func main() {
    chaincode, err := kalpsdk.NewChaincode(&SmartContract{})
    if err != nil {
        fmt.Printf("Error creating SBT chaincode: %v \n", err)
        return
    }
    chaincode.Start()
}

Bootstraps the contract for deployment on the blockchain.

6. Deploying the Contract

Next comes deploying the smart contract on the blockchain. For that, we need no fuss, and the KALP Instant Deployer is the go-to solution.

To explore how we can use KID to seamlessly deploy the smart contract, check out our previous article: https://dev.to/kalpstudio/deploying-your-first-smart-contract-using-kid-step-by-step-276p

7. Interacting with the Contract

Now, once the contract is deployed, to interact with your front-end, you can jump to KALP STUDIO’s API Gateway. Check out our previous blog here: https://dev.to/kalpstudio/how-to-set-up-and-manage-apis-using-kalp-studios-api-gateway-1me0

8. Real-World Use Case

University Diplomas:

  • Each graduate receives an SBT that can’t be transferred.
  • Employers can query the token to verify credentials.
  • Revocation could be implemented for invalid cases.

9. Full Source Code

If you feel that you’re stuck on midway, please take a look at the source code for your reference here: https://github.com/thekalpstudio/StudioLabs/tree/main/Certify/contracts

In the coming articles, we will be exploring more smart contracts and use cases for better understanding, and getting a better understanding of how we can leverage the power of KALP STUDIO plarform.


This content originally appeared on DEV Community and was authored by Asjad Ahmed Khan