diff --git a/contracts/ERC1155WithERC20.sol b/contracts/ERC1155WithERC20.sol new file mode 100644 index 0000000..e8c761f --- /dev/null +++ b/contracts/ERC1155WithERC20.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Supply.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/access/AccessControl.sol"; + +/** + * @title ERC1155WithERC20 + * @dev ERC1155 token where token ID 0 also implements the ERC20 interface. + */ +contract ERC1155WithERC20 is ERC1155Supply, IERC20, AccessControl { + string public name; + string public symbol; + uint8 public constant decimals = 18; + + mapping(address => mapping(address => uint256)) private _allowances; + uint256 private _totalSupply; + + bytes32 public constant BRAND_ADMIN_ROLE = keccak256("BRAND_ADMIN_ROLE"); + bytes32 public constant BRAND_MINTER_ROLE = keccak256("BRAND_MINTER_ROLE"); + + mapping(uint256 => address) private _tokenBrands; + + constructor(string memory name_, string memory symbol_, string memory uri_) ERC1155(uri_) { + name = name_; + symbol = symbol_; + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + } + + function brandRole(bytes32 role, address brand) public pure returns (bytes32) { + return keccak256(abi.encodePacked(role, brand)); + } + + function createBrand(address brand, address admin) external onlyRole(DEFAULT_ADMIN_ROLE) { + bytes32 adminRole = brandRole(BRAND_ADMIN_ROLE, brand); + bytes32 minterRole = brandRole(BRAND_MINTER_ROLE, brand); + _setRoleAdmin(minterRole, adminRole); + _grantRole(adminRole, admin); + } + + function grantBrandRole(address brand, bytes32 role, address account) external onlyRole(brandRole(BRAND_ADMIN_ROLE, brand)) { + _grantRole(brandRole(role, brand), account); + } + + function revokeBrandRole(address brand, bytes32 role, address account) external onlyRole(brandRole(BRAND_ADMIN_ROLE, brand)) { + _revokeRole(brandRole(role, brand), account); + } + + function assignTokenToBrand(uint256 id, address brand) external onlyRole(DEFAULT_ADMIN_ROLE) { + _tokenBrands[id] = brand; + } + + function brandOf(uint256 id) public view returns (address) { + return _tokenBrands[id]; + } + + // ERC20 + function totalSupply() public view override returns (uint256) { + return _totalSupply; + } + + function balanceOf(address account) public view override(IERC20) returns (uint256) { + return super.balanceOf(account, 0); + } + + function transfer(address to, uint256 amount) public override returns (bool) { + _safeTransferFrom(msg.sender, to, 0, amount, ""); + return true; + } + + function allowance(address owner, address spender) public view override returns (uint256) { + return _allowances[owner][spender]; + } + + function approve(address spender, uint256 amount) public override returns (bool) { + _allowances[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public override returns (bool) { + uint256 currentAllowance = _allowances[from][msg.sender]; + require(currentAllowance >= amount, "ERC20: insufficient allowance"); + unchecked { + _allowances[from][msg.sender] = currentAllowance - amount; + } + _safeTransferFrom(from, to, 0, amount, ""); + return true; + } + + // Minting and burning + function mint(address to, uint256 id, uint256 amount, bytes memory data) public { + address brand = _tokenBrands[id]; + require(brand != address(0), "ERC1155WithERC20: brand not set"); + require(hasRole(brandRole(BRAND_MINTER_ROLE, brand), msg.sender), "ERC1155WithERC20: missing brand minter role"); + if (id == 0) { + _totalSupply += amount; + } + _mint(to, id, amount, data); + } + + function burn(address from, uint256 id, uint256 amount) public { + require(from == msg.sender || isApprovedForAll(from, msg.sender), "ERC1155: caller is not owner nor approved"); + if (id == 0) { + _totalSupply -= amount; + } + _burn(from, id, amount); + } + + function _beforeTokenTransfer( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) internal override(ERC1155Supply) { + super._beforeTokenTransfer(operator, from, to, ids, amounts, data); + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC1155, AccessControl) + returns (bool) + { + return super.supportsInterface(interfaceId); + } +} diff --git a/test/ERC1155WithERC20.test.js b/test/ERC1155WithERC20.test.js new file mode 100644 index 0000000..b4ee6df --- /dev/null +++ b/test/ERC1155WithERC20.test.js @@ -0,0 +1,101 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); + +describe("ERC1155WithERC20", function () { + beforeEach(async function () { + [ + this.deployer, + this.brand, + this.minter, + this.addr1, + this.addr2, + this.addr3, + ] = await ethers.getSigners(); + const Token = await ethers.getContractFactory("ERC1155WithERC20"); + this.token = await Token.deploy("Token", "TKN", "uri/"); + await this.token.deployed(); + + await this.token.createBrand(this.brand.address, this.brand.address); + await this.token.assignTokenToBrand(0, this.brand.address); + await this.token.assignTokenToBrand(1, this.brand.address); + await this.token + .connect(this.brand) + .grantBrandRole( + this.brand.address, + await this.token.BRAND_MINTER_ROLE(), + this.minter.address + ); + }); + + it("requires brand minter role to mint", async function () { + expect(await this.token.brandOf(0)).to.equal(this.brand.address); + + await expect( + this.token.mint(this.addr1.address, 0, 1, "0x") + ).to.be.revertedWith("ERC1155WithERC20: missing brand minter role"); + + await this.token + .connect(this.minter) + .mint(this.addr1.address, 0, 1, "0x"); + expect( + await this.token["balanceOf(address)"](this.addr1.address) + ).to.equal(1); + }); + + it("mints id 0 as ERC20 and tracks supply", async function () { + await this.token + .connect(this.minter) + .mint(this.addr1.address, 0, 100, "0x"); + expect(await this.token["totalSupply()"]()).to.equal(100); + expect(await this.token["totalSupply(uint256)"](0)).to.equal(100); + expect(await this.token["balanceOf(address)"](this.addr1.address)).to.equal(100); + expect(await this.token["balanceOf(address,uint256)"](this.addr1.address, 0)).to.equal(100); + }); + + it("handles ERC20 transfers and allowances for id 0", async function () { + await this.token + .connect(this.minter) + .mint(this.addr1.address, 0, 100, "0x"); + + await this.token.connect(this.addr1).transfer(this.addr2.address, 40); + expect(await this.token["balanceOf(address)"](this.addr1.address)).to.equal(60); + expect(await this.token["balanceOf(address)"](this.addr2.address)).to.equal(40); + + await this.token.connect(this.addr1).approve(this.addr2.address, 50); + expect(await this.token.allowance(this.addr1.address, this.addr2.address)).to.equal(50); + + await this.token.connect(this.addr2).transferFrom(this.addr1.address, this.addr3.address, 30); + expect(await this.token["balanceOf(address)"](this.addr1.address)).to.equal(30); + expect(await this.token["balanceOf(address)"](this.addr3.address)).to.equal(30); + expect(await this.token.allowance(this.addr1.address, this.addr2.address)).to.equal(20); + }); + + it("burns id 0 and updates ERC20 supply", async function () { + await this.token + .connect(this.minter) + .mint(this.addr1.address, 0, 50, "0x"); + await this.token.connect(this.addr1).burn(this.addr1.address, 0, 20); + expect(await this.token["totalSupply()"]()).to.equal(30); + expect(await this.token["totalSupply(uint256)"](0)).to.equal(30); + expect(await this.token["balanceOf(address)"](this.addr1.address)).to.equal(30); + }); + + it("processes non-zero ids as ERC1155 tokens", async function () { + await this.token + .connect(this.minter) + .mint(this.addr1.address, 1, 10, "0x"); + expect(await this.token["totalSupply()"]()).to.equal(0); + expect(await this.token["totalSupply(uint256)"](1)).to.equal(10); + expect(await this.token["balanceOf(address,uint256)"](this.addr1.address, 1)).to.equal(10); + + await this.token.connect(this.addr1).safeTransferFrom(this.addr1.address, this.addr2.address, 1, 3, "0x"); + expect(await this.token["balanceOf(address,uint256)"](this.addr1.address, 1)).to.equal(7); + expect(await this.token["balanceOf(address,uint256)"](this.addr2.address, 1)).to.equal(3); + + await this.token.connect(this.addr1).burn(this.addr1.address, 1, 2); + expect(await this.token["balanceOf(address,uint256)"](this.addr1.address, 1)).to.equal(5); + expect(await this.token["totalSupply(uint256)"](1)).to.equal(8); + expect(await this.token["totalSupply()"]()).to.equal(0); + }); +}); +