Procházet zdrojové kódy

Add ERC6909 Implementation along with extensions (#5394)

Co-authored-by: Hadrien Croubois <hadrien.croubois@gmail.com>
Co-authored-by: Ernesto García <ernestognw@gmail.com>
Arr00 před 8 měsíci
rodič
revize
43b3319e5b

+ 5 - 0
.changeset/brown-turkeys-marry.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`ER6909TokenSupply`: Add an extension of ERC6909 which tracks total supply for each token id.

+ 5 - 0
.changeset/dirty-bananas-shake.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`ERC6909ContentURI`: Add an extension of ERC6909 which adds content URI functionality.

+ 5 - 0
.changeset/proud-cooks-do.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`ERC6909Metadata`: Add an extension of ERC6909 which adds metadata functionality.

+ 5 - 0
.changeset/ten-hats-begin.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`ERC6909`: Add a standard implementation of ERC6909.

+ 12 - 0
contracts/interfaces/README.adoc

@@ -40,6 +40,10 @@ are useful to interact with third party contracts that implement them.
 - {IERC5313}
 - {IERC5805}
 - {IERC6372}
+- {IERC6909}
+- {IERC6909ContentURI}
+- {IERC6909Metadata}
+- {IERC6909TokenSupply}
 - {IERC7674}
 
 == Detailed ABI
@@ -84,4 +88,12 @@ are useful to interact with third party contracts that implement them.
 
 {{IERC6372}}
 
+{{IERC6909}}
+
+{{IERC6909ContentURI}}
+
+{{IERC6909Metadata}}
+
+{{IERC6909TokenSupply}}
+
 {{IERC7674}}

+ 26 - 0
contracts/mocks/docs/token/ERC6909/ERC6909GameItems.sol

@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20;
+
+import {ERC6909Metadata} from "../../../../token/ERC6909/extensions/draft-ERC6909Metadata.sol";
+
+contract ERC6909GameItems is ERC6909Metadata {
+    uint256 public constant GOLD = 0;
+    uint256 public constant SILVER = 1;
+    uint256 public constant THORS_HAMMER = 2;
+    uint256 public constant SWORD = 3;
+    uint256 public constant SHIELD = 4;
+
+    constructor() {
+        _setDecimals(GOLD, 18);
+        _setDecimals(SILVER, 18);
+        // Default decimals is 0
+        _setDecimals(SWORD, 9);
+        _setDecimals(SHIELD, 9);
+
+        _mint(msg.sender, GOLD, 10 ** 18);
+        _mint(msg.sender, SILVER, 10_000 ** 18);
+        _mint(msg.sender, THORS_HAMMER, 1);
+        _mint(msg.sender, SWORD, 10 ** 9);
+        _mint(msg.sender, SHIELD, 10 ** 9);
+    }
+}

+ 27 - 0
contracts/token/ERC6909/README.adoc

@@ -0,0 +1,27 @@
+= ERC-6909
+
+[.readme-notice]
+NOTE: This document is better viewed at https://docs.openzeppelin.com/contracts/api/token/erc6909
+
+This set of interfaces and contracts are all related to the https://eips.ethereum.org/EIPS/eip-6909[ERC-6909 Minimal Multi-Token Interface].
+
+The ERC consists of four interfaces which fulfill different roles--the interfaces are as follows:
+
+. {IERC6909}: Base interface for a vanilla ERC6909 token.
+. {IERC6909ContentURI}: Extends the base interface and adds content URI (contract and token level) functionality.
+. {IERC6909Metadata}: Extends the base interface and adds metadata functionality, which exposes a name, symbol, and decimals for each token id.
+. {IERC6909TokenSupply}: Extends the base interface and adds total supply functionality for each token id.
+
+Implementations are provided for each of the 4 interfaces defined in the ERC.
+
+== Core
+
+{{ERC6909}}
+
+== Extensions
+
+{{ERC6909ContentURI}}
+
+{{ERC6909Metadata}}
+
+{{ERC6909TokenSupply}}

+ 224 - 0
contracts/token/ERC6909/draft-ERC6909.sol

@@ -0,0 +1,224 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {IERC6909} from "../../interfaces/draft-IERC6909.sol";
+import {Context} from "../../utils/Context.sol";
+import {IERC165, ERC165} from "../../utils/introspection/ERC165.sol";
+
+/**
+ * @dev Implementation of ERC-6909.
+ * See https://eips.ethereum.org/EIPS/eip-6909
+ */
+contract ERC6909 is Context, ERC165, IERC6909 {
+    mapping(address owner => mapping(uint256 id => uint256)) private _balances;
+
+    mapping(address owner => mapping(address operator => bool)) private _operatorApprovals;
+
+    mapping(address owner => mapping(address spender => mapping(uint256 id => uint256))) private _allowances;
+
+    error ERC6909InsufficientBalance(address sender, uint256 balance, uint256 needed, uint256 id);
+    error ERC6909InsufficientAllowance(address spender, uint256 allowance, uint256 needed, uint256 id);
+    error ERC6909InvalidApprover(address approver);
+    error ERC6909InvalidReceiver(address receiver);
+    error ERC6909InvalidSender(address sender);
+    error ERC6909InvalidSpender(address spender);
+
+    /// @inheritdoc IERC165
+    function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) {
+        return interfaceId == type(IERC6909).interfaceId || super.supportsInterface(interfaceId);
+    }
+
+    /// @inheritdoc IERC6909
+    function balanceOf(address owner, uint256 id) public view virtual override returns (uint256) {
+        return _balances[owner][id];
+    }
+
+    /// @inheritdoc IERC6909
+    function allowance(address owner, address spender, uint256 id) public view virtual override returns (uint256) {
+        return _allowances[owner][spender][id];
+    }
+
+    /// @inheritdoc IERC6909
+    function isOperator(address owner, address spender) public view virtual override returns (bool) {
+        return _operatorApprovals[owner][spender];
+    }
+
+    /// @inheritdoc IERC6909
+    function approve(address spender, uint256 id, uint256 amount) public virtual override returns (bool) {
+        _approve(_msgSender(), spender, id, amount);
+        return true;
+    }
+
+    /// @inheritdoc IERC6909
+    function setOperator(address spender, bool approved) public virtual override returns (bool) {
+        _setOperator(_msgSender(), spender, approved);
+        return true;
+    }
+
+    /// @inheritdoc IERC6909
+    function transfer(address receiver, uint256 id, uint256 amount) public virtual override returns (bool) {
+        _transfer(_msgSender(), receiver, id, amount);
+        return true;
+    }
+
+    /// @inheritdoc IERC6909
+    function transferFrom(
+        address sender,
+        address receiver,
+        uint256 id,
+        uint256 amount
+    ) public virtual override returns (bool) {
+        address caller = _msgSender();
+        if (sender != caller && !isOperator(sender, caller)) {
+            _spendAllowance(sender, caller, id, amount);
+        }
+        _transfer(sender, receiver, id, amount);
+        return true;
+    }
+
+    /**
+     * @dev Creates `amount` of token `id` and assigns them to `account`, by transferring it from address(0).
+     * Relies on the `_update` mechanism
+     *
+     * Emits a {Transfer} event with `from` set to the zero address.
+     *
+     * NOTE: This function is not virtual, {_update} should be overridden instead.
+     */
+    function _mint(address to, uint256 id, uint256 amount) internal {
+        if (to == address(0)) {
+            revert ERC6909InvalidReceiver(address(0));
+        }
+        _update(address(0), to, id, amount);
+    }
+
+    /**
+     * @dev Moves `amount` of token `id` from `from` to `to` without checking for approvals.
+     *
+     * This internal function is equivalent to {transfer}, and can be used to
+     * e.g. implement automatic token fees, slashing mechanisms, etc.
+     *
+     * Emits a {Transfer} event.
+     *
+     * NOTE: This function is not virtual, {_update} should be overridden instead.
+     */
+    function _transfer(address from, address to, uint256 id, uint256 amount) internal {
+        if (from == address(0)) {
+            revert ERC6909InvalidSender(address(0));
+        }
+        if (to == address(0)) {
+            revert ERC6909InvalidReceiver(address(0));
+        }
+        _update(from, to, id, amount);
+    }
+
+    /**
+     * @dev Destroys a `amount` of token `id` from `account`.
+     * Relies on the `_update` mechanism.
+     *
+     * Emits a {Transfer} event with `to` set to the zero address.
+     *
+     * NOTE: This function is not virtual, {_update} should be overridden instead
+     */
+    function _burn(address from, uint256 id, uint256 amount) internal {
+        if (from == address(0)) {
+            revert ERC6909InvalidSender(address(0));
+        }
+        _update(from, address(0), id, amount);
+    }
+
+    /**
+     * @dev Transfers `amount` of token `id` from `from` to `to`, or alternatively mints (or burns) if `from`
+     * (or `to`) is the zero address. All customizations to transfers, mints, and burns should be done by overriding
+     * this function.
+     *
+     * Emits a {Transfer} event.
+     */
+    function _update(address from, address to, uint256 id, uint256 amount) internal virtual {
+        address caller = _msgSender();
+
+        if (from != address(0)) {
+            uint256 fromBalance = _balances[from][id];
+            if (fromBalance < amount) {
+                revert ERC6909InsufficientBalance(from, fromBalance, amount, id);
+            }
+            unchecked {
+                // Overflow not possible: amount <= fromBalance.
+                _balances[from][id] = fromBalance - amount;
+            }
+        }
+        if (to != address(0)) {
+            _balances[to][id] += amount;
+        }
+
+        emit Transfer(caller, from, to, id, amount);
+    }
+
+    /**
+     * @dev Sets `amount` as the allowance of `spender` over the `owner`'s `id` tokens.
+     *
+     * This internal function is equivalent to `approve`, and can be used to e.g. set automatic allowances for certain
+     * subsystems, etc.
+     *
+     * Emits an {Approval} event.
+     *
+     * Requirements:
+     *
+     * - `owner` cannot be the zero address.
+     * - `spender` cannot be the zero address.
+     */
+    function _approve(address owner, address spender, uint256 id, uint256 amount) internal virtual {
+        if (owner == address(0)) {
+            revert ERC6909InvalidApprover(address(0));
+        }
+        if (spender == address(0)) {
+            revert ERC6909InvalidSpender(address(0));
+        }
+        _allowances[owner][spender][id] = amount;
+        emit Approval(owner, spender, id, amount);
+    }
+
+    /**
+     * @dev Approve `spender` to operate on all of `owner`'s tokens
+     *
+     * This internal function is equivalent to `setOperator`, and can be used to e.g. set automatic allowances for
+     * certain subsystems, etc.
+     *
+     * Emits an {OperatorSet} event.
+     *
+     * Requirements:
+     *
+     * - `owner` cannot be the zero address.
+     * - `spender` cannot be the zero address.
+     */
+    function _setOperator(address owner, address spender, bool approved) internal virtual {
+        if (owner == address(0)) {
+            revert ERC6909InvalidApprover(address(0));
+        }
+        if (spender == address(0)) {
+            revert ERC6909InvalidSpender(address(0));
+        }
+        _operatorApprovals[owner][spender] = approved;
+        emit OperatorSet(owner, spender, approved);
+    }
+
+    /**
+     * @dev Updates `owner`'s allowance for `spender` based on spent `amount`.
+     *
+     * Does not update the allowance value in case of infinite allowance.
+     * Revert if not enough allowance is available.
+     *
+     * Does not emit an {Approval} event.
+     */
+    function _spendAllowance(address owner, address spender, uint256 id, uint256 amount) internal virtual {
+        uint256 currentAllowance = allowance(owner, spender, id);
+        if (currentAllowance < type(uint256).max) {
+            if (currentAllowance < amount) {
+                revert ERC6909InsufficientAllowance(spender, currentAllowance, amount, id);
+            }
+            unchecked {
+                _allowances[owner][spender][id] = currentAllowance - amount;
+            }
+        }
+    }
+}

+ 52 - 0
contracts/token/ERC6909/extensions/draft-ERC6909ContentURI.sol

@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {ERC6909} from "../draft-ERC6909.sol";
+import {IERC6909ContentURI} from "../../../interfaces/draft-IERC6909.sol";
+
+/**
+ * @dev Implementation of the Content URI extension defined in ERC6909.
+ */
+contract ERC6909ContentURI is ERC6909, IERC6909ContentURI {
+    string private _contractURI;
+    mapping(uint256 id => string) private _tokenURIs;
+
+    /// @dev Event emitted when the contract URI is changed. See https://eips.ethereum.org/EIPS/eip-7572[ERC-7572] for details.
+    event ContractURIUpdated();
+
+    /// @dev See {IERC1155-URI}
+    event URI(string value, uint256 indexed id);
+
+    /// @inheritdoc IERC6909ContentURI
+    function contractURI() public view virtual override returns (string memory) {
+        return _contractURI;
+    }
+
+    /// @inheritdoc IERC6909ContentURI
+    function tokenURI(uint256 id) public view virtual override returns (string memory) {
+        return _tokenURIs[id];
+    }
+
+    /**
+     * @dev Sets the {contractURI} for the contract.
+     *
+     * Emits a {ContractURIUpdated} event.
+     */
+    function _setContractURI(string memory newContractURI) internal virtual {
+        _contractURI = newContractURI;
+
+        emit ContractURIUpdated();
+    }
+
+    /**
+     * @dev Sets the {tokenURI} for a given token of type `id`.
+     *
+     * Emits a {URI} event.
+     */
+    function _setTokenURI(uint256 id, string memory newTokenURI) internal virtual {
+        _tokenURIs[id] = newTokenURI;
+
+        emit URI(newTokenURI, id);
+    }
+}

+ 76 - 0
contracts/token/ERC6909/extensions/draft-ERC6909Metadata.sol

@@ -0,0 +1,76 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {ERC6909} from "../draft-ERC6909.sol";
+import {IERC6909Metadata} from "../../../interfaces/draft-IERC6909.sol";
+
+/**
+ * @dev Implementation of the Metadata extension defined in ERC6909. Exposes the name, symbol, and decimals of each token id.
+ */
+contract ERC6909Metadata is ERC6909, IERC6909Metadata {
+    struct TokenMetadata {
+        string name;
+        string symbol;
+        uint8 decimals;
+    }
+
+    mapping(uint256 id => TokenMetadata) private _tokenMetadata;
+
+    /// @dev The name of the token of type `id` was updated to `newName`.
+    event ERC6909NameUpdated(uint256 indexed id, string newName);
+
+    /// @dev The symbol for the token of type `id` was updated to `newSymbol`.
+    event ERC6909SymbolUpdated(uint256 indexed id, string newSymbol);
+
+    /// @dev The decimals value for token of type `id` was updated to `newDecimals`.
+    event ERC6909DecimalsUpdated(uint256 indexed id, uint8 newDecimals);
+
+    /// @inheritdoc IERC6909Metadata
+    function name(uint256 id) public view virtual override returns (string memory) {
+        return _tokenMetadata[id].name;
+    }
+
+    /// @inheritdoc IERC6909Metadata
+    function symbol(uint256 id) public view virtual override returns (string memory) {
+        return _tokenMetadata[id].symbol;
+    }
+
+    /// @inheritdoc IERC6909Metadata
+    function decimals(uint256 id) public view virtual override returns (uint8) {
+        return _tokenMetadata[id].decimals;
+    }
+
+    /**
+     * @dev Sets the `name` for a given token of type `id`.
+     *
+     * Emits an {ERC6909NameUpdated} event.
+     */
+    function _setName(uint256 id, string memory newName) internal virtual {
+        _tokenMetadata[id].name = newName;
+
+        emit ERC6909NameUpdated(id, newName);
+    }
+
+    /**
+     * @dev Sets the `symbol` for a given token of type `id`.
+     *
+     * Emits an {ERC6909SymbolUpdated} event.
+     */
+    function _setSymbol(uint256 id, string memory newSymbol) internal virtual {
+        _tokenMetadata[id].symbol = newSymbol;
+
+        emit ERC6909SymbolUpdated(id, newSymbol);
+    }
+
+    /**
+     * @dev Sets the `decimals` for a given token of type `id`.
+     *
+     * Emits an {ERC6909DecimalsUpdated} event.
+     */
+    function _setDecimals(uint256 id, uint8 newDecimals) internal virtual {
+        _tokenMetadata[id].decimals = newDecimals;
+
+        emit ERC6909DecimalsUpdated(id, newDecimals);
+    }
+}

+ 34 - 0
contracts/token/ERC6909/extensions/draft-ERC6909TokenSupply.sol

@@ -0,0 +1,34 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {ERC6909} from "../draft-ERC6909.sol";
+import {IERC6909TokenSupply} from "../../../interfaces/draft-IERC6909.sol";
+
+/**
+ * @dev Implementation of the Token Supply extension defined in ERC6909.
+ * Tracks the total supply of each token id individually.
+ */
+contract ERC6909TokenSupply is ERC6909, IERC6909TokenSupply {
+    mapping(uint256 id => uint256) private _totalSupplies;
+
+    /// @inheritdoc IERC6909TokenSupply
+    function totalSupply(uint256 id) public view virtual override returns (uint256) {
+        return _totalSupplies[id];
+    }
+
+    /// @dev Override the `_update` function to update the total supply of each token id as necessary.
+    function _update(address from, address to, uint256 id, uint256 amount) internal virtual override {
+        super._update(from, to, id, amount);
+
+        if (from == address(0)) {
+            _totalSupplies[id] += amount;
+        }
+        if (to == address(0)) {
+            unchecked {
+                // amount <= _balances[id][from] <= _totalSupplies[id]
+                _totalSupplies[id] -= amount;
+            }
+        }
+    }
+}

+ 1 - 0
docs/modules/ROOT/nav.adoc

@@ -13,6 +13,7 @@
 ** xref:erc721.adoc[ERC-721]
 ** xref:erc1155.adoc[ERC-1155]
 ** xref:erc4626.adoc[ERC-4626]
+** xref:erc6909.adoc[ERC-6909]
 
 * xref:governance.adoc[Governance]
 

+ 47 - 0
docs/modules/ROOT/pages/erc6909.adoc

@@ -0,0 +1,47 @@
+= ERC-6909
+
+ERC-6909 is a draft EIP that draws on ERC-1155 learnings since it was published in 2018. The main goals of ERC-6909 is to decrease gas costs and complexity--this is mainly accomplished by removing batching and callbacks.
+
+TIP: To understand the inspiration for a multi token standard, see the xref:erc1155.adoc#multi-token-standard[multi token standard] section within the EIP-1155 docs.
+
+== Changes from ERC-1155
+
+There are three main changes from ERC-1155 which are as follows:
+
+. The removal of batch operations.
+. The removal of transfer callbacks.
+. Granularization in approvals--approvals can be set globally (as operators) or as amounts per token (inspired by ERC20).
+
+== Constructing an ERC-6909 Token Contract
+
+We'll use ERC-6909 to track multiple items in a game, each having their own unique attributes. All item types will by minted to the deployer of the contract, which we can later transfer to players. We'll also use the xref:api:token/ERC6909.adoc#ERC6909Metadata[`ERC6909Metadata`] extension to add decimals to our fungible items (the vanilla ERC-6909 implementation does not have decimals).
+
+For simplicity, we will mint all items in the constructor--however, minting functionality could be added to the contract to mint on demand to players.
+
+TIP: For an overview of minting mechanisms, check out xref:erc20-supply.adoc[Creating ERC-20 Supply].
+
+Here's what a contract for tokenized items might look like:
+
+[source,solidity]
+----
+include::api:example$token/ERC6909/ERC6909GameItems.sol[]
+----
+
+Note that there is no content URI functionality in the base implementation, but the xref:api:token/ERC6909.adoc#ERC6909ContentURI[`ERC6909ContentURI`] extension adds it. Additionally, the base implementation does not track total supplies, but the xref:api:token/ERC6909.adoc#ERC6909TokenSupply[`ERC6909TokenSupply`] extension tracks the total supply of each token id.
+
+Once the contract is deployed, we will be able to query the deployer’s balance:
+[source,javascript]
+----
+> gameItems.balanceOf(deployerAddress, 3)
+1000000000
+----
+
+We can transfer items to player accounts:
+[source,javascript]
+----
+> gameItems.transfer(playerAddress, 2, 1)
+> gameItems.balanceOf(playerAddress, 2)
+1
+> gameItems.balanceOf(deployerAddress, 2)
+0
+----

+ 216 - 0
test/token/ERC6909/ERC6909.behavior.js

@@ -0,0 +1,216 @@
+const { ethers } = require('hardhat');
+const { expect } = require('chai');
+
+const { shouldSupportInterfaces } = require('../../utils/introspection/SupportsInterface.behavior');
+
+function shouldBehaveLikeERC6909() {
+  const firstTokenId = 1n;
+  const secondTokenId = 2n;
+  const randomTokenId = 125523n;
+
+  const firstTokenSupply = 2000n;
+  const secondTokenSupply = 3000n;
+  const amount = 100n;
+
+  describe('like an ERC6909', function () {
+    describe('balanceOf', function () {
+      describe("when accounts don't own tokens", function () {
+        it('return zero', async function () {
+          await expect(this.token.balanceOf(this.holder, firstTokenId)).to.eventually.be.equal(0n);
+          await expect(this.token.balanceOf(this.holder, secondTokenId)).to.eventually.be.equal(0n);
+          await expect(this.token.balanceOf(this.other, randomTokenId)).to.eventually.be.equal(0n);
+        });
+      });
+
+      describe('when accounts own some tokens', function () {
+        beforeEach(async function () {
+          await this.token.$_mint(this.holder, firstTokenId, firstTokenSupply);
+          await this.token.$_mint(this.holder, secondTokenId, secondTokenSupply);
+        });
+
+        it('returns amount owned by the given address', async function () {
+          await expect(this.token.balanceOf(this.holder, firstTokenId)).to.eventually.be.equal(firstTokenSupply);
+          await expect(this.token.balanceOf(this.holder, secondTokenId)).to.eventually.be.equal(secondTokenSupply);
+          await expect(this.token.balanceOf(this.other, firstTokenId)).to.eventually.be.equal(0n);
+        });
+      });
+    });
+
+    describe('setOperator', function () {
+      it('emits an an OperatorSet event and updated the value', async function () {
+        await expect(this.token.connect(this.holder).setOperator(this.operator, true))
+          .to.emit(this.token, 'OperatorSet')
+          .withArgs(this.holder, this.operator, true);
+
+        // operator for holder
+        await expect(this.token.isOperator(this.holder, this.operator)).to.eventually.be.true;
+
+        // not operator for other account
+        await expect(this.token.isOperator(this.other, this.operator)).to.eventually.be.false;
+      });
+
+      it('can unset the operator approval', async function () {
+        await this.token.connect(this.holder).setOperator(this.operator, true);
+
+        // before
+        await expect(this.token.isOperator(this.holder, this.operator)).to.eventually.be.true;
+
+        // unset
+        await expect(this.token.connect(this.holder).setOperator(this.operator, false))
+          .to.emit(this.token, 'OperatorSet')
+          .withArgs(this.holder, this.operator, false);
+
+        // after
+        await expect(this.token.isOperator(this.holder, this.operator)).to.eventually.be.false;
+      });
+
+      it('cannot set address(0) as an operator', async function () {
+        await expect(this.token.connect(this.holder).setOperator(ethers.ZeroAddress, true))
+          .to.be.revertedWithCustomError(this.token, 'ERC6909InvalidSpender')
+          .withArgs(ethers.ZeroAddress);
+      });
+    });
+
+    describe('approve', function () {
+      it('emits an Approval event and updates allowance', async function () {
+        await expect(this.token.connect(this.holder).approve(this.operator, firstTokenId, firstTokenSupply))
+          .to.emit(this.token, 'Approval')
+          .withArgs(this.holder, this.operator, firstTokenId, firstTokenSupply);
+
+        // approved
+        await expect(this.token.allowance(this.holder, this.operator, firstTokenId)).to.eventually.be.equal(
+          firstTokenSupply,
+        );
+        // other account is not approved
+        await expect(this.token.allowance(this.other, this.operator, firstTokenId)).to.eventually.be.equal(0n);
+      });
+
+      it('can unset the approval', async function () {
+        await expect(this.token.connect(this.holder).approve(this.operator, firstTokenId, 0n))
+          .to.emit(this.token, 'Approval')
+          .withArgs(this.holder, this.operator, firstTokenId, 0n);
+        await expect(this.token.allowance(this.holder, this.operator, firstTokenId)).to.eventually.be.equal(0n);
+      });
+
+      it('cannot give allowance to address(0)', async function () {
+        await expect(this.token.connect(this.holder).approve(ethers.ZeroAddress, firstTokenId, firstTokenSupply))
+          .to.be.revertedWithCustomError(this.token, 'ERC6909InvalidSpender')
+          .withArgs(ethers.ZeroAddress);
+      });
+    });
+
+    describe('transfer', function () {
+      beforeEach(async function () {
+        await this.token.$_mint(this.holder, firstTokenId, firstTokenSupply);
+        await this.token.$_mint(this.holder, secondTokenId, secondTokenSupply);
+      });
+
+      it('transfers to the zero address are blocked', async function () {
+        await expect(this.token.connect(this.holder).transfer(ethers.ZeroAddress, firstTokenId, firstTokenSupply))
+          .to.be.revertedWithCustomError(this.token, 'ERC6909InvalidReceiver')
+          .withArgs(ethers.ZeroAddress);
+      });
+
+      it('reverts when insufficient balance', async function () {
+        await expect(this.token.connect(this.holder).transfer(this.recipient, firstTokenId, firstTokenSupply + 1n))
+          .to.be.revertedWithCustomError(this.token, 'ERC6909InsufficientBalance')
+          .withArgs(this.holder, firstTokenSupply, firstTokenSupply + 1n, firstTokenId);
+      });
+
+      it('emits event and transfers tokens', async function () {
+        await expect(this.token.connect(this.holder).transfer(this.recipient, firstTokenId, amount))
+          .to.emit(this.token, 'Transfer')
+          .withArgs(this.holder, this.holder, this.recipient, firstTokenId, amount);
+
+        await expect(this.token.balanceOf(this.holder, firstTokenId)).to.eventually.equal(firstTokenSupply - amount);
+        await expect(this.token.balanceOf(this.recipient, firstTokenId)).to.eventually.equal(amount);
+      });
+    });
+
+    describe('transferFrom', function () {
+      beforeEach(async function () {
+        await this.token.$_mint(this.holder, firstTokenId, firstTokenSupply);
+        await this.token.$_mint(this.holder, secondTokenId, secondTokenSupply);
+      });
+
+      it('transfer from self', async function () {
+        await expect(this.token.connect(this.holder).transferFrom(this.holder, this.recipient, firstTokenId, amount))
+          .to.emit(this.token, 'Transfer')
+          .withArgs(this.holder, this.holder, this.recipient, firstTokenId, amount);
+
+        await expect(this.token.balanceOf(this.holder, firstTokenId)).to.eventually.equal(firstTokenSupply - amount);
+        await expect(this.token.balanceOf(this.recipient, firstTokenId)).to.eventually.equal(amount);
+      });
+
+      describe('with approval', async function () {
+        beforeEach(async function () {
+          await this.token.connect(this.holder).approve(this.operator, firstTokenId, amount);
+        });
+
+        it('reverts when insufficient allowance', async function () {
+          await expect(
+            this.token.connect(this.operator).transferFrom(this.holder, this.recipient, firstTokenId, amount + 1n),
+          )
+            .to.be.revertedWithCustomError(this.token, 'ERC6909InsufficientAllowance')
+            .withArgs(this.operator, amount, amount + 1n, firstTokenId);
+        });
+
+        it('should emit transfer event and update approval (without an Approval event)', async function () {
+          await expect(
+            this.token.connect(this.operator).transferFrom(this.holder, this.recipient, firstTokenId, amount - 1n),
+          )
+            .to.emit(this.token, 'Transfer')
+            .withArgs(this.operator, this.holder, this.recipient, firstTokenId, amount - 1n)
+            .to.not.emit(this.token, 'Approval');
+
+          await expect(this.token.allowance(this.holder, this.operator, firstTokenId)).to.eventually.equal(1n);
+        });
+
+        it("shouldn't reduce allowance when infinite", async function () {
+          await this.token.connect(this.holder).approve(this.operator, firstTokenId, ethers.MaxUint256);
+
+          await this.token.connect(this.operator).transferFrom(this.holder, this.recipient, firstTokenId, amount);
+
+          await expect(this.token.allowance(this.holder, this.operator, firstTokenId)).to.eventually.equal(
+            ethers.MaxUint256,
+          );
+        });
+      });
+    });
+
+    describe('with operator approval', function () {
+      beforeEach(async function () {
+        await this.token.connect(this.holder).setOperator(this.operator, true);
+        await this.token.$_mint(this.holder, firstTokenId, firstTokenSupply);
+      });
+
+      it('operator can transfer', async function () {
+        await expect(this.token.connect(this.operator).transferFrom(this.holder, this.recipient, firstTokenId, amount))
+          .to.emit(this.token, 'Transfer')
+          .withArgs(this.operator, this.holder, this.recipient, firstTokenId, amount);
+
+        await expect(this.token.balanceOf(this.holder, firstTokenId)).to.eventually.equal(firstTokenSupply - amount);
+        await expect(this.token.balanceOf(this.recipient, firstTokenId)).to.eventually.equal(amount);
+      });
+
+      it('operator transfer does not reduce allowance', async function () {
+        // Also give allowance
+        await this.token.connect(this.holder).approve(this.operator, firstTokenId, firstTokenSupply);
+
+        await expect(this.token.connect(this.operator).transferFrom(this.holder, this.recipient, firstTokenId, amount))
+          .to.emit(this.token, 'Transfer')
+          .withArgs(this.operator, this.holder, this.recipient, firstTokenId, amount);
+
+        await expect(this.token.allowance(this.holder, this.operator, firstTokenId)).to.eventually.equal(
+          firstTokenSupply,
+        );
+      });
+    });
+
+    shouldSupportInterfaces(['ERC6909']);
+  });
+}
+
+module.exports = {
+  shouldBehaveLikeERC6909,
+};

+ 104 - 0
test/token/ERC6909/ERC6909.test.js

@@ -0,0 +1,104 @@
+const { ethers } = require('hardhat');
+const { expect } = require('chai');
+const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
+
+const { shouldBehaveLikeERC6909 } = require('./ERC6909.behavior');
+
+async function fixture() {
+  const [holder, operator, recipient, other] = await ethers.getSigners();
+  const token = await ethers.deployContract('$ERC6909');
+  return { token, holder, operator, recipient, other };
+}
+
+describe('ERC6909', function () {
+  beforeEach(async function () {
+    Object.assign(this, await loadFixture(fixture));
+  });
+
+  shouldBehaveLikeERC6909();
+
+  describe('internal functions', function () {
+    const tokenId = 1990n;
+    const mintValue = 9001n;
+    const burnValue = 3000n;
+
+    describe('_mint', function () {
+      it('reverts with a zero destination address', async function () {
+        await expect(this.token.$_mint(ethers.ZeroAddress, tokenId, mintValue))
+          .to.be.revertedWithCustomError(this.token, 'ERC6909InvalidReceiver')
+          .withArgs(ethers.ZeroAddress);
+      });
+
+      describe('with minted tokens', function () {
+        beforeEach(async function () {
+          this.tx = await this.token.connect(this.operator).$_mint(this.holder, tokenId, mintValue);
+        });
+
+        it('emits a Transfer event from 0 address', async function () {
+          await expect(this.tx)
+            .to.emit(this.token, 'Transfer')
+            .withArgs(this.operator, ethers.ZeroAddress, this.holder, tokenId, mintValue);
+        });
+
+        it('credits the minted token value', async function () {
+          await expect(this.token.balanceOf(this.holder, tokenId)).to.eventually.be.equal(mintValue);
+        });
+      });
+    });
+
+    describe('_transfer', function () {
+      it('reverts when transferring from the zero address', async function () {
+        await expect(this.token.$_transfer(ethers.ZeroAddress, this.holder, 1n, 1n))
+          .to.be.revertedWithCustomError(this.token, 'ERC6909InvalidSender')
+          .withArgs(ethers.ZeroAddress);
+      });
+
+      it('reverts when transferring to the zero address', async function () {
+        await expect(this.token.$_transfer(this.holder, ethers.ZeroAddress, 1n, 1n))
+          .to.be.revertedWithCustomError(this.token, 'ERC6909InvalidReceiver')
+          .withArgs(ethers.ZeroAddress);
+      });
+    });
+
+    describe('_burn', function () {
+      it('reverts with a zero from address', async function () {
+        await expect(this.token.$_burn(ethers.ZeroAddress, tokenId, burnValue))
+          .to.be.revertedWithCustomError(this.token, 'ERC6909InvalidSender')
+          .withArgs(ethers.ZeroAddress);
+      });
+
+      describe('with burned tokens', function () {
+        beforeEach(async function () {
+          await this.token.connect(this.operator).$_mint(this.holder, tokenId, mintValue);
+          this.tx = await this.token.connect(this.operator).$_burn(this.holder, tokenId, burnValue);
+        });
+
+        it('emits a Transfer event to 0 address', async function () {
+          await expect(this.tx)
+            .to.emit(this.token, 'Transfer')
+            .withArgs(this.operator, this.holder, ethers.ZeroAddress, tokenId, burnValue);
+        });
+
+        it('debits the burned token value', async function () {
+          await expect(this.token.balanceOf(this.holder, tokenId)).to.eventually.be.equal(mintValue - burnValue);
+        });
+      });
+    });
+
+    describe('_approve', function () {
+      it('reverts when the owner is the zero address', async function () {
+        await expect(this.token.$_approve(ethers.ZeroAddress, this.recipient, 1n, 1n))
+          .to.be.revertedWithCustomError(this.token, 'ERC6909InvalidApprover')
+          .withArgs(ethers.ZeroAddress);
+      });
+    });
+
+    describe('_setOperator', function () {
+      it('reverts when the owner is the zero address', async function () {
+        await expect(this.token.$_setOperator(ethers.ZeroAddress, this.operator, true))
+          .to.be.revertedWithCustomError(this.token, 'ERC6909InvalidApprover')
+          .withArgs(ethers.ZeroAddress);
+      });
+    });
+  });
+});

+ 49 - 0
test/token/ERC6909/extensions/ERC6909ContentURI.test.js

@@ -0,0 +1,49 @@
+const { ethers } = require('hardhat');
+const { expect } = require('chai');
+const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
+
+async function fixture() {
+  const token = await ethers.deployContract('$ERC6909ContentURI');
+  return { token };
+}
+
+describe('ERC6909ContentURI', function () {
+  beforeEach(async function () {
+    Object.assign(this, await loadFixture(fixture));
+  });
+
+  describe('contractURI', function () {
+    it('is empty string be default', async function () {
+      await expect(this.token.contractURI()).to.eventually.equal('');
+    });
+
+    it('is settable by internal setter', async function () {
+      await this.token.$_setContractURI('https://example.com');
+      await expect(this.token.contractURI()).to.eventually.equal('https://example.com');
+    });
+
+    it('emits an event when set', async function () {
+      await expect(this.token.$_setContractURI('https://example.com')).to.emit(this.token, 'ContractURIUpdated');
+    });
+  });
+
+  describe('tokenURI', function () {
+    it('is empty string be default', async function () {
+      await expect(this.token.tokenURI(1n)).to.eventually.equal('');
+    });
+
+    it('can be set by dedicated setter', async function () {
+      await this.token.$_setTokenURI(1n, 'https://example.com/1');
+      await expect(this.token.tokenURI(1n)).to.eventually.equal('https://example.com/1');
+
+      // Only set for the specified token ID
+      await expect(this.token.tokenURI(2n)).to.eventually.equal('');
+    });
+
+    it('emits an event when set', async function () {
+      await expect(this.token.$_setTokenURI(1n, 'https://example.com/1'))
+        .to.emit(this.token, 'URI')
+        .withArgs('https://example.com/1', 1n);
+    });
+  });
+});

+ 58 - 0
test/token/ERC6909/extensions/ERC6909Metadata.test.js

@@ -0,0 +1,58 @@
+const { ethers } = require('hardhat');
+const { expect } = require('chai');
+const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
+
+async function fixture() {
+  const token = await ethers.deployContract('$ERC6909Metadata');
+  return { token };
+}
+
+describe('ERC6909Metadata', function () {
+  beforeEach(async function () {
+    Object.assign(this, await loadFixture(fixture));
+  });
+
+  describe('name', function () {
+    it('is empty string be default', async function () {
+      await expect(this.token.name(1n)).to.eventually.equal('');
+    });
+
+    it('can be set by dedicated setter', async function () {
+      await expect(this.token.$_setName(1n, 'My Token'))
+        .to.emit(this.token, 'ERC6909NameUpdated')
+        .withArgs(1n, 'My Token');
+      await expect(this.token.name(1n)).to.eventually.equal('My Token');
+
+      // Only set for the specified token ID
+      await expect(this.token.name(2n)).to.eventually.equal('');
+    });
+  });
+
+  describe('symbol', function () {
+    it('is empty string be default', async function () {
+      await expect(this.token.symbol(1n)).to.eventually.equal('');
+    });
+
+    it('can be set by dedicated setter', async function () {
+      await expect(this.token.$_setSymbol(1n, 'MTK')).to.emit(this.token, 'ERC6909SymbolUpdated').withArgs(1n, 'MTK');
+      await expect(this.token.symbol(1n)).to.eventually.equal('MTK');
+
+      // Only set for the specified token ID
+      await expect(this.token.symbol(2n)).to.eventually.equal('');
+    });
+  });
+
+  describe('decimals', function () {
+    it('is 0 by default', async function () {
+      await expect(this.token.decimals(1n)).to.eventually.equal(0);
+    });
+
+    it('can be set by dedicated setter', async function () {
+      await expect(this.token.$_setDecimals(1n, 18)).to.emit(this.token, 'ERC6909DecimalsUpdated').withArgs(1n, 18);
+      await expect(this.token.decimals(1n)).to.eventually.equal(18);
+
+      // Only set for the specified token ID
+      await expect(this.token.decimals(2n)).to.eventually.equal(0);
+    });
+  });
+});

+ 53 - 0
test/token/ERC6909/extensions/ERC6909TokenSupply.test.js

@@ -0,0 +1,53 @@
+const { ethers } = require('hardhat');
+const { expect } = require('chai');
+const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
+
+const { shouldBehaveLikeERC6909 } = require('../ERC6909.behavior');
+
+async function fixture() {
+  const [holder, operator, recipient, other] = await ethers.getSigners();
+  const token = await ethers.deployContract('$ERC6909TokenSupply');
+  return { token, holder, operator, recipient, other };
+}
+
+describe('ERC6909TokenSupply', function () {
+  beforeEach(async function () {
+    Object.assign(this, await loadFixture(fixture));
+  });
+
+  shouldBehaveLikeERC6909();
+
+  describe('totalSupply', function () {
+    it('is zero before any mint', async function () {
+      await expect(this.token.totalSupply(1n)).to.eventually.be.equal(0n);
+    });
+
+    it('minting tokens increases the total supply', async function () {
+      await this.token.$_mint(this.holder, 1n, 17n);
+      await expect(this.token.totalSupply(1n)).to.eventually.be.equal(17n);
+    });
+
+    describe('with tokens minted', function () {
+      const supply = 1000n;
+
+      beforeEach(async function () {
+        await this.token.$_mint(this.holder, 1n, supply);
+      });
+
+      it('burning tokens decreases the total supply', async function () {
+        await this.token.$_burn(this.holder, 1n, 17n);
+        await expect(this.token.totalSupply(1n)).to.eventually.be.equal(supply - 17n);
+      });
+
+      it('supply unaffected by transfers', async function () {
+        await this.token.$_transfer(this.holder, this.recipient, 1n, 42n);
+        await expect(this.token.totalSupply(1n)).to.eventually.be.equal(supply);
+      });
+
+      it('supply unaffected by no-op', async function () {
+        await this.token.$_update(ethers.ZeroAddress, ethers.ZeroAddress, 1n, 42n);
+        await expect(this.token.totalSupply(1n)).to.eventually.be.equal(supply);
+      });
+    });
+  });
+});

+ 9 - 0
test/utils/introspection/SupportsInterface.behavior.js

@@ -90,6 +90,15 @@ const SIGNATURES = {
   Governor: GOVERNOR_INTERFACE,
   Governor_5_3: GOVERNOR_INTERFACE.concat('getProposalId(address[],uint256[],bytes[],bytes32)'),
   ERC2981: ['royaltyInfo(uint256,uint256)'],
+  ERC6909: [
+    'balanceOf(address,uint256)',
+    'allowance(address,address,uint256)',
+    'isOperator(address,address)',
+    'transfer(address,uint256,uint256)',
+    'transferFrom(address,address,uint256,uint256)',
+    'approve(address,uint256,uint256)',
+    'setOperator(address,bool)',
+  ],
 };
 
 const INTERFACE_IDS = mapValues(SIGNATURES, interfaceId);