DEV Community

Felicia Laurent
Felicia Laurent

Posted on

How to Build an ERC-1155 Semi-Fungible Token Contract from Scratch

If you've worked with ERC-20 or ERC-721 and are picking up ERC-1155 for the first time, the mental model shift is the important part - everything else follows from it. ERC-1155 doesn't manage one token type per contract. It manages a registry of token IDs inside a single contract, and each ID can behave as fungible, non-fungible, or anything in between depending on how you configure supply.

This walkthrough builds a working ERC-1155 contract from scratch, explains why each piece exists, and flags the parts that are easy to get wrong.

Prerequisites

Solidity ^0.8.20
Hardhat or Foundry (examples below use Hardhat syntax)
OpenZeppelin Contracts v5.x
Node.js 18+
Install the dependency:
npm install @openzeppelin/contracts@5

What We're Building

A semi-fungible token contract that can:

Mint multiple token types (IDs) under one contract
Support batch minting and batch transfers
Attach per-token metadata via URI
Include basic access control so only an authorized minter can create new supply
This is a foundational contract; production deployments would layer compliance and pausability on top, which I'll flag but not fully implement here, since that logic is use-case specific.

Contract Structure

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

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

contract SemiFungibleAsset is ERC1155, Ownable {
using Strings for uint256;

string public name = "Semi-Fungible Asset Token";
mapping(uint256 => uint256) public totalSupply;

constructor(string memory baseUri)
    ERC1155(baseUri)
    Ownable(msg.sender)
{}

function uri(uint256 id) public view override returns (string memory) {
    return string(abi.encodePacked(super.uri(id), id.toString(), ".json"));
}

function mint(address to, uint256 id, uint256 amount, bytes memory data)
    external
    onlyOwner
{
    totalSupply[id] += amount;
    _mint(to, id, amount, data);
}

function mintBatch(
    address to,
    uint256[] memory ids,
    uint256[] memory amounts,
    bytes memory data
) external onlyOwner {
    for (uint256 i = 0; i < ids.length; i++) {
        totalSupply[ids[i]] += amounts[i];
    }
    _mintBatch(to, ids, amounts, data);
}
Enter fullscreen mode Exit fullscreen mode

}

What's actually happening here

ERC1155(baseUri): the base URI is a template; the uri() override appends the token ID so each token type can resolve to its own metadata JSON. This is the standard pattern, not a shortcut.

onlyOwner on minting: this is intentionally minimal. In a real fintech deployment, you'd almost certainly replace this with role-based access control (AccessControl from OpenZeppelin) so minting, pausing, and admin functions have separate permissions instead of one owner key controlling everything.

totalSupply mapping: ERC-1155 doesn't track total supply per ID natively the way ERC-20 tracks total supply. If your application needs that number on-chain (for reporting, for compliance, for a cap check), you have to track it yourself, which is what this mapping does.

Batch Transfers: The Actual Point of ERC-1155

The efficiency case for ERC-1155 lives in this function, which you get for free from the OpenZeppelin base contract:

function safeBatchTransferFrom(
address from,
address to,
uint256[] memory ids,
uint256[] memory amounts,
bytes memory data
) public virtual override

A user holding fractional positions across five different token IDs can move all five in a single transaction and a single gas payment, instead of five separate ERC-20-style transfers. This is the concrete mechanism behind the gas savings claim you'll see in comparisons between ERC-1155 and deploying multiple ERC-20 contracts; it's not a marketing number, it's this function.

Testing the Contract

A minimal Hardhat test to confirm minting and batch transfer behavior:
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("SemiFungibleAsset", function () {
it("mints and batch transfers correctly", async function () {
const [owner, user] = await ethers.getSigners();
const Token = await ethers.getContractFactory("SemiFungibleAsset");
const token = await Token.deploy("https://example.com/metadata/");

await token.mintBatch(owner.address, [1, 2], [100, 50], "0x");
expect(await token.balanceOf(owner.address, 1)).to.equal(100);
expect(await token.balanceOf(owner.address, 2)).to.equal(50);

await token.safeBatchTransferFrom(
  owner.address, user.address, [1, 2], [10, 5], "0x"
);
expect(await token.balanceOf(user.address, 1)).to.equal(10);
Enter fullscreen mode Exit fullscreen mode

});
});

Run it with:
npx hardhat test

Upgrading From a Single Owner to Role-Based Access Control

The onlyOwner pattern above works for a demo, but it's a real liability in production: one compromised key can mint unlimited supply, and there's no way to separate "who can mint" from "who can pause the contract" or "who can update metadata." OpenZeppelin's AccessControl fixes this by letting you define separate roles instead of one all-powerful owner.

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

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

contract SemiFungibleAssetV2 is ERC1155, AccessControl {
using Strings for uint256;

bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

mapping(uint256 => uint256) public totalSupply;

constructor(string memory baseUri) ERC1155(baseUri) {
    _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
    _grantRole(MINTER_ROLE, msg.sender);
    _grantRole(PAUSER_ROLE, msg.sender);
}

function uri(uint256 id) public view override returns (string memory) {
    return string(abi.encodePacked(super.uri(id), id.toString(), ".json"));
}

function mint(address to, uint256 id, uint256 amount, bytes memory data)
    external
    onlyRole(MINTER_ROLE)
{
    totalSupply[id] += amount;
    _mint(to, id, amount, data);
}

function mintBatch(
    address to,
    uint256[] memory ids,
    uint256[] memory amounts,
    bytes memory data
) external onlyRole(MINTER_ROLE) {
    for (uint256 i = 0; i < ids.length; i++) {
        totalSupply[ids[i]] += amounts[i];
    }
    _mintBatch(to, ids, amounts, data);
}

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

}

What changed and why it matters:

MINTER_ROLE and PAUSER_ROLE are separate. In a real deployment, the wallet that mints new supply doesn't have to be the same wallet that can halt the contract in an emergency, splitting these limits the damage if any single key is compromised.

DEFAULT_ADMIN_ROLE manages the other roles. This is the wallet that can grant or revoke MINTER_ROLE and PAUSER_ROLE; in production, this is usually a multisig, not a single EOA, precisely because it's the highest-privilege role in the system.

supportsInterface needs an explicit override. Both ERC1155 and AccessControl implement supportsInterface, so Solidity requires you to resolve the conflict explicitly. This is a common compile error the first time someone combines the two.

This isn't a cosmetic change. Moving from a single owner key to role-based access control is one of the more consistent recommendations that comes out of smart contract audits, and it's worth building in from the start rather than retrofitting after a security review flags it.

Gas and Security Notes Worth Taking Seriously

Reentrancy on transfers. safeTransferFrom and safeBatchTransferFrom call onERC1155Received on the recipient if it's a contract. That external call is a reentrancy surface; don't add state changes after the transfer call in any function you write that wraps these.

Unbounded loops in batch functions. mintBatch and safeBatchTransferFrom loop over arrays with no length cap in the base implementation. If your application lets users submit these arrays, enforce a reasonable max length, or a large enough array can push a transaction past the block gas limit.

This contract has not been audited. It's a teaching example, not production code. Anything managing real value and especially anything touching RWA tokenization, where actual asset value is on the line, needs an independent security audit before deployment. Saying otherwise would be irresponsible.

Where This Fits Into a Larger System

This contract is the token layer only. A real ERC-1155 token development effort for a fintech or RWA use case typically adds: role-based access control, pausability for emergency stops, transfer restrictions for compliance (KYC allowlists, jurisdiction gating), and an off-chain or oracle-fed metadata pipeline for asset data. Those layers are where most of the actual engineering time goes the base ERC-1155 contract shown here is closer to the starting point than the finish line.

If there's interest, a follow-up post can go through adding AccessControl and a basic transfer allowlist for compliance. Let me know in the comments if that's the direction worth taking next.

Top comments (0)