DEV Community

Drilon Hametaj
Drilon Hametaj

Posted on

Building a Soulbound Token (SBT) System on Polygon — Full Walkthrough

I built a credential certification platform using Soulbound Tokens on Polygon. Not a tutorial project — a production system with thousands of certificates issued. Here's the full technical breakdown.

The Problem

A client needed to issue professional certifications that:

  • Could be verified by anyone, instantly, without calling the issuer
  • Could never be falsified or duplicated
  • Would persist even if the issuing company shut down
  • Would belong to the recipient, not to a centralized database PDFs fail all four criteria. A centralized database fails the last two. Blockchain solves all four.

Why Soulbound Tokens

Regular NFTs (ERC-721) can be transferred. That's the whole point of NFTs — ownership and transferability. But for credentials, transferability is a bug, not a feature. You don't want someone selling their medical license on OpenSea.

Soulbound Tokens are non-transferable NFTs. Once minted to a wallet, they stay there forever. The concept was proposed by Vitalik Buterin in the "Decentralized Society" paper.

Why Polygon

Gas costs. Deploying on Ethereum mainnet: $50-500 depending on congestion. Minting each certificate on Ethereum: $5-50. For a system that issues hundreds of certificates, that's unsustainable.

Polygon: deploy cost ~$0.01. Mint cost ~$0.001. Same Solidity code. Same EVM. Same security model (anchored to Ethereum).

For a deeper comparison, I wrote a full analysis of Polygon vs Ethereum for business use cases.

The Smart Contract

Here's the core of the SBT contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract SoulboundCertificate is ERC721, ERC721URIStorage, Ownable {
    uint256 private _nextTokenId;

    constructor() ERC721("SoulboundCertificate", "SBC") Ownable(msg.sender) {}

    function mint(address to, string memory uri) public onlyOwner returns (uint256) {
        uint256 tokenId = _nextTokenId++;
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, uri);
        return tokenId;
    }

    // Block ALL transfers — this is what makes it "soulbound"
    function _update(
        address to,
        uint256 tokenId,
        address auth
    ) internal override returns (address) {
        address from = _ownerOf(tokenId);
        // Allow minting (from == address(0)) and burning (to == address(0))
        // Block all transfers between non-zero addresses
        if (from != address(0) && to != address(0)) {
            revert("SBT: transfer not allowed");
        }
        return super._update(to, tokenId, auth);
    }

    // Required overrides
    function tokenURI(uint256 tokenId)
        public view override(ERC721, ERC721URIStorage) returns (string memory)
    {
        return super.tokenURI(tokenId);
    }

    function supportsInterface(bytes4 interfaceId)
        public view override(ERC721, ERC721URIStorage) returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}
Enter fullscreen mode Exit fullscreen mode

Key decisions:

  • OpenZeppelin base: never roll your own ERC-721. OpenZeppelin is battle-tested and audited.
  • _update override: in OZ v5, this is the single function that controls all token movements. Override it once, block transfers everywhere.
  • onlyOwner for minting: only the issuing organization can create certificates. No public minting. ## Metadata on IPFS

Certificate data (name, course, date, score) is stored as JSON on IPFS, not on-chain. On-chain storage is expensive and unnecessary for metadata.

{
  "name": "Advanced Solidity Development",
  "description": "Professional certification issued by [Organization]",
  "image": "ipfs://QmXxx.../certificate-image.png",
  "attributes": [
    { "trait_type": "Recipient", "value": "Mario Rossi" },
    { "trait_type": "Course", "value": "Advanced Solidity" },
    { "trait_type": "Date", "value": "2026-01-15" },
    { "trait_type": "Score", "value": "92/100" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The tokenURI points to the IPFS hash. IPFS is content-addressed — the hash IS the content. If anyone changes a single byte, the hash changes. Tamper-proof by design.

The Frontend

Built with Next.js + Ethers.js. Two interfaces:

Admin Panel (authenticated, for the issuer):

  • Form: recipient wallet address, course details, date
  • Generates JSON metadata → uploads to IPFS → calls mint()
  • Dashboard of all issued certificates Public Verification (open to anyone):
  • Enter a wallet address → see all SBTs held by that address
  • Each certificate shows the full metadata pulled from IPFS
  • Link to the transaction on Polygonscan for on-chain proof
// Simplified verification logic
const provider = new ethers.JsonRpcProvider(POLYGON_RPC);
const contract = new ethers.Contract(SBT_ADDRESS, ABI, provider);

const balance = await contract.balanceOf(walletAddress);
const certificates = [];

for (let i = 0; i < balance; i++) {
  const tokenId = await contract.tokenOfOwnerByIndex(walletAddress, i);
  const uri = await contract.tokenURI(tokenId);
  const metadata = await fetch(uri.replace('ipfs://', IPFS_GATEWAY));
  certificates.push(await metadata.json());
}
Enter fullscreen mode Exit fullscreen mode

Testing

Full test suite with Hardhat:

describe("SoulboundCertificate", () => {
  it("should mint to recipient", async () => {
    const tx = await sbt.mint(recipient.address, "ipfs://test");
    expect(await sbt.ownerOf(0)).to.equal(recipient.address);
  });

  it("should block transfers", async () => {
    await sbt.mint(recipient.address, "ipfs://test");
    await expect(
      sbt.connect(recipient).transferFrom(recipient.address, other.address, 0)
    ).to.be.revertedWith("SBT: transfer not allowed");
  });

  it("should allow burning by owner", async () => {
    // Revocation mechanism — issuer can burn if needed
  });
});
Enter fullscreen mode Exit fullscreen mode

Plus Slither for static analysis and manual review for logic bugs. I wrote about my full smart contract audit process separately.

Results

  • Deployed on Polygon mainnet
  • Thousands of certificates issued
  • Verification time: ~3 seconds (vs days for traditional verification)
  • Cost per certificate: < $0.01
  • Zero downtime since launch ## When to Use This Pattern

SBTs make sense for: professional certifications, academic credentials, membership badges, compliance attestations, and any credential that should be permanent, non-transferable, and publicly verifiable.

They don't make sense for: temporary access tokens, transferable assets, or anything where privacy is critical (blockchain is public — consider ZK proofs if privacy matters).


I'm a blockchain developer building smart contracts, wallets, and DApps for businesses. If you're working on something similar, let's talk.

Top comments (0)