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>
pull/2274/head
Arr00 3 days ago committed by GitHub
parent df878c87fc
commit 43b3319e5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/brown-turkeys-marry.md
  2. 5
      .changeset/dirty-bananas-shake.md
  3. 5
      .changeset/proud-cooks-do.md
  4. 5
      .changeset/ten-hats-begin.md
  5. 12
      contracts/interfaces/README.adoc
  6. 26
      contracts/mocks/docs/token/ERC6909/ERC6909GameItems.sol
  7. 27
      contracts/token/ERC6909/README.adoc
  8. 224
      contracts/token/ERC6909/draft-ERC6909.sol
  9. 52
      contracts/token/ERC6909/extensions/draft-ERC6909ContentURI.sol
  10. 76
      contracts/token/ERC6909/extensions/draft-ERC6909Metadata.sol
  11. 34
      contracts/token/ERC6909/extensions/draft-ERC6909TokenSupply.sol
  12. 1
      docs/modules/ROOT/nav.adoc
  13. 47
      docs/modules/ROOT/pages/erc6909.adoc
  14. 216
      test/token/ERC6909/ERC6909.behavior.js
  15. 104
      test/token/ERC6909/ERC6909.test.js
  16. 49
      test/token/ERC6909/extensions/ERC6909ContentURI.test.js
  17. 58
      test/token/ERC6909/extensions/ERC6909Metadata.test.js
  18. 53
      test/token/ERC6909/extensions/ERC6909TokenSupply.test.js
  19. 9
      test/utils/introspection/SupportsInterface.behavior.js

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

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

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

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

@ -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}}

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

@ -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}}

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

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

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

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

@ -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]

@ -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
----

@ -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,
};

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

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

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

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

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

Loading…
Cancel
Save