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

Collapse
X
 
  • Time
  • Show
Clear All
new posts
  • MyrinNew
    Senior Member
    • Feb 2024
    • 5175

    #1

    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);
    }
    }







    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" }
    ]
    }







    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());
    }







    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.addr ess, other.address, 0)
    ).to.be.revertedWith("SBT: transfer not allowed");
    });

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







    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:
    • 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.




    More...
Working...