diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73281cc2c..1031cc2ab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
+### Added
+
+- ERC-6909 standard implementation and extensions (#1594)
+ - Added `ERC6909Component`, `ERC6909ContentURIComponent`, `ERC6909MetadataComponent`, and `ERC6909TokenSupplyComponent`.
+ - Added interfaces and ABI traits: `IERC6909`, `ERC6909ABI`, `IERC6909Metadata`, `ERC6909MetadataABI`, `IERC6909TokenSupply`, `ERC6909TokenSupplyABI`, `IERC6909ContentUri`, and `IERC6909ContentUriABI`.
+ - Added mocks: `ERC6909Mock`, `ERC6909MockWithHooks`, `ERC6909ContentURIMock`, `ERC6909MetadataMock`, `ERC6909TokenSupplyMock`.
+ - Added tests for components.
+
+- Integration of ERC-6909 components into the `with_components` macro (#1594)
+ - Added `ComponentInfo` implementations for ERC-6909 and its extensions.
+ - Added new diagnostic `ERC6909_HOOKS_IMPL_MISSING`.
+ - Added parser validation for missing hooks implementations.
+
## 3.0.0-alpha.3 (2025-10-9)
### Added
diff --git a/docs/antora.yml b/docs/antora.yml
index 43f89d8df..9902025a3 100644
--- a/docs/antora.yml
+++ b/docs/antora.yml
@@ -5,4 +5,4 @@ nav:
- modules/ROOT/nav.adoc
asciidoc:
attributes:
- page-sidebar-collapse-default: 'Access,Accounts,Finance,Governance,Introspection,Security,ERC20,ERC721,ERC1155,ERC4626,Upgrades,Universal Deployer Contract'
+ page-sidebar-collapse-default: 'Access,Accounts,Finance,Governance,Introspection,Security,ERC20,ERC721,ERC1155,ERC4626,ERC6909,Upgrades,Universal Deployer Contract'
diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc
index ce7efc7df..d236bfbab 100644
--- a/docs/modules/ROOT/nav.adoc
+++ b/docs/modules/ROOT/nav.adoc
@@ -53,6 +53,8 @@
**** xref:/api/erc1155.adoc[API Reference]
*** xref:erc4626.adoc[ERC4626]
**** xref:/api/erc20.adoc#ERC4626Component[API Reference]
+*** xref:erc6909.adoc[ERC6909]
+**** xref:/api/erc6909.adoc[API Reference]
*** xref:/api/token_common.adoc[Common]
** xref:udc.adoc[Universal Deployer Contract]
diff --git a/docs/modules/ROOT/pages/api/erc6909.adoc b/docs/modules/ROOT/pages/api/erc6909.adoc
new file mode 100644
index 000000000..34e758395
--- /dev/null
+++ b/docs/modules/ROOT/pages/api/erc6909.adoc
@@ -0,0 +1,766 @@
+:github-icon: pass:[]
+:eip6909: https://eips.ethereum.org/EIPS/eip-6909[EIP6909]
+:inner-src5: xref:api/introspection.adoc#ISRC5[SRC5 ID]
+
+= ERC6909
+
+include::../utils/_common.adoc[]
+
+This module provides interfaces, presets, and utilities related to ERC6909 contracts.
+
+TIP: For an overview of ERC6909, read our xref:erc6909.adoc[ERC6909 guide].
+
+== Interfaces
+
+NOTE: Starting from version `3.x.x`, the interfaces are no longer part of the `openzeppelin_token` package. The references
+documented here are contained in the `openzeppelin_interfaces` package version `v{current_openzeppelin_interfaces_version}`.
+
+[.contract]
+[[IERC6909]]
+=== `++IERC6909++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v3.0.0-alpha.3/packages/interfaces/src/token/erc6909.cairo[{github-icon},role=heading-link]
+
+[.hljs-theme-dark]
+```cairo
+use openzeppelin_interfaces::erc6909::IERC6909;
+```
+
+Interface of the IERC6909 minimal multi-token standard as defined in {eip6909}.
+
+[.contract-index]
+.{inner-src5}
+--
+0xd5aa138060489fd9c4592f77a16011cc5615ce4d292ee1f7873ae65c43b6bb
+--
+
+[.contract-index]
+.Functions
+--
+* xref:#IERC6909-balance_of[`++balance_of(owner, id)++`]
+* xref:#IERC6909-allowance[`++allowance(owner, spender, id)++`]
+* xref:#IERC6909-is_operator[`++is_operator(owner, spender)++`]
+* xref:#IERC6909-transfer[`++transfer(receiver, id, amount)++`]
+* xref:#IERC6909-transfer_from[`++transfer_from(sender, receiver, id, amount)++`]
+* xref:#IERC6909-approve[`++approve(spender, id, amount)++`]
+* xref:#IERC6909-set_operator[`++set_operator(spender, approved)++`]
+--
+
+[.contract-index]
+.Events
+--
+* xref:#IERC6909-Transfer[`++Transfer(caller, sender, receiver, id, amount)++`]
+* xref:#IERC6909-Approval[`++Approval(owner, spender, id, amount)++`]
+* xref:#IERC6909-OperatorSet[`++OperatorSet(owner, spender, approved)++`]
+--
+
+==== Functions
+
+[.contract-item]
+[[IERC6909-balance_of]]
+==== `[.contract-item-name]#++balance_of++#++(owner: ContractAddress, id: u256) → u256++` [.item-kind]#external#
+
+Returns the amount of `id` tokens owned by `owner`.
+
+[.contract-item]
+[[IERC6909-allowance]]
+==== `[.contract-item-name]#++allowance++#++(owner: ContractAddress, spender: ContractAddress, id: u256) → u256++` [.item-kind]#external#
+
+Returns the remaining number of `id` tokens that `spender` is allowed to spend on behalf of `owner`
+through <>.
+
+[.contract-item]
+[[IERC6909-is_operator]]
+==== `[.contract-item-name]#++is_operator++#++(owner: ContractAddress, spender: ContractAddress) → bool++` [.item-kind]#external#
+
+Returns `true` if `spender` is approved by `owner` as an operator over all token IDs.
+
+[.contract-item]
+[[IERC6909-transfer]]
+==== `[.contract-item-name]#++transfer++#++(receiver: ContractAddress, id: u256, amount: u256) → bool++` [.item-kind]#external#
+
+Transfers `amount` of token `id` from the caller to `receiver`.
+
+Emits a <> event.
+
+[.contract-item]
+[[IERC6909-transfer_from]]
+==== `[.contract-item-name]#++transfer_from++#++(sender: ContractAddress, receiver: ContractAddress, id: u256, amount: u256) → bool++` [.item-kind]#external#
+
+Transfers `amount` of token `id` from `sender` to `receiver`, deducting from the caller's allowance if required.
+
+Emits a <> event.
+
+[.contract-item]
+[[IERC6909-approve]]
+==== `[.contract-item-name]#++approve++#++(spender: ContractAddress, id: u256, amount: u256) → bool++` [.item-kind]#external#
+
+Sets `amount` as the allowance of `spender` over the caller's tokens of ID `id`.
+
+Emits an <> event.
+
+[.contract-item]
+[[IERC6909-set_operator]]
+==== `[.contract-item-name]#++set_operator++#++(spender: ContractAddress, approved: bool) → bool++` [.item-kind]#external#
+
+Sets or unsets `spender` as an operator for the caller, granting or revoking permission to transfer
+any of the caller's token IDs.
+
+Emits an <> event.
+
+==== Events
+
+[.contract-item]
+[[IERC6909-Transfer]]
+==== `[.contract-item-name]#++Transfer++#++(caller: ContractAddress, sender: ContractAddress, receiver: ContractAddress, id: u256, amount: u256)++` [.item-kind]#event#
+
+Emitted when `amount` tokens of ID `id` are moved from `sender` to `receiver` by `caller`.
+
+[.contract-item]
+[[IERC6909-Approval]]
+==== `[.contract-item-name]#++Approval++#++(owner: ContractAddress, spender: ContractAddress, id: u256, amount: u256)++` [.item-kind]#event#
+
+Emitted when the allowance of `spender` for `owner` is set to `amount` for token `id`.
+
+[.contract-item]
+[[IERC6909-OperatorSet]]
+==== `[.contract-item-name]#++OperatorSet++#++(owner: ContractAddress, spender: ContractAddress, approved: bool)++` [.item-kind]#event#
+
+Emitted when `owner` enables or disables `spender` as an operator over all token IDs.
+
+[.contract]
+[[IERC6909ContentUri]]
+=== `++IERC6909ContentUri++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v3.0.0-alpha.3/packages/interfaces/src/token/erc6909.cairo[{github-icon},role=heading-link]
+
+[.hljs-theme-dark]
+```cairo
+use openzeppelin_interfaces::erc6909::IERC6909ContentUri;
+```
+
+Interface for contract-level and token-level URIs in {eip6909}.
+
+[.contract-index]
+.{inner-src5}
+--
+0x356efd8b40a01c1525c7d0ecafbe3b82a47df564fdd496727effe6336526f05
+--
+
+[.contract-index]
+.Functions
+--
+* xref:#IERC6909ContentUri-contract_uri[`++contract_uri()++`]
+* xref:#IERC6909ContentUri-token_uri[`++token_uri(id)++`]
+--
+
+==== Functions
+
+[.contract-item]
+[[IERC6909ContentUri-contract_uri]]
+==== `[.contract-item-name]#++contract_uri++#++() → ByteArray++` [.item-kind]#external#
+
+Returns the contract-level URI.
+
+[.contract-item]
+[[IERC6909ContentUri-token_uri]]
+==== `[.contract-item-name]#++token_uri++#++(id: u256) → ByteArray++` [.item-kind]#external#
+
+Returns the token-level URI for token `id`.
+
+[.contract]
+[[IERC6909Metadata]]
+=== `++IERC6909Metadata++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v3.0.0-alpha.3/packages/interfaces/src/token/erc6909.cairo[{github-icon},role=heading-link]
+
+[.hljs-theme-dark]
+```cairo
+use openzeppelin_interfaces::erc6909::IERC6909Metadata;
+```
+
+Interface for per-token metadata in {eip6909}.
+
+[.contract-index]
+.{inner-src5}
+--
+0x19aa0b778d120d5294054319458ee8886514766411c50dceddd9463712d6011
+--
+
+[.contract-index]
+.Functions
+--
+* xref:#IERC6909Metadata-name[`++name(id)++`]
+* xref:#IERC6909Metadata-symbol[`++symbol(id)++`]
+* xref:#IERC6909Metadata-decimals[`++decimals(id)++`]
+--
+
+==== Functions
+
+[.contract-item]
+[[IERC6909Metadata-name]]
+==== `[.contract-item-name]#++name++#++(id: u256) → ByteArray++` [.item-kind]#external#
+
+Returns the name of token `id`.
+
+[.contract-item]
+[[IERC6909Metadata-symbol]]
+==== `[.contract-item-name]#++symbol++#++(id: u256) → ByteArray++` [.item-kind]#external#
+
+Returns the symbol of token `id`.
+
+[.contract-item]
+[[IERC6909Metadata-decimals]]
+==== `[.contract-item-name]#++decimals++#++(id: u256) → u8++` [.item-kind]#external#
+
+Returns the number of decimals of token `id`.
+
+[.contract]
+[[IERC6909TokenSupply]]
+=== `++IERC6909TokenSupply++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v3.0.0-alpha.3/packages/interfaces/src/token/erc6909.cairo[{github-icon},role=heading-link]
+
+[.hljs-theme-dark]
+```cairo
+use openzeppelin_interfaces::erc6909::IERC6909TokenSupply;
+```
+
+Interface for querying per-token total supply in {eip6909}.
+
+[.contract-index]
+.{inner-src5}
+--
+0x3a632c15cb93b574eb9166de70521abbeab5c2eb4fdab9930729bba8658c41
+--
+
+[.contract-index]
+.Functions
+--
+* xref:#IERC6909TokenSupply-total_supply[`++total_supply(id)++`]
+--
+
+==== Functions
+
+[.contract-item]
+[[IERC6909TokenSupply-total_supply]]
+==== `[.contract-item-name]#++total_supply++#++(id: u256) → u256++` [.item-kind]#external#
+
+Returns the total supply of token `id`.
+
+== Core
+
+[.contract]
+[[ERC6909Component]]
+=== `++ERC6909Component++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v3.0.0-alpha.3/packages/token/src/erc6909/erc6909.cairo[{github-icon},role=heading-link]
+
+[.hljs-theme-dark]
+```cairo
+use openzeppelin_token::erc6909::ERC6909Component;
+```
+
+ERC6909 component implementing <>.
+
+NOTE: {src5-component-required-note}
+
+NOTE: See xref:#ERC6909Component-Hooks[Hooks] to understand how hooks are used.
+
+[.contract-index]
+.Hooks
+--
+[.sub-index#ERC6909Component-ERC6909HooksTrait]
+.ERC6909HooksTrait
+* xref:#ERC6909Component-before_update[`++before_update(self, from, recipient, id, amount)++`]
+* xref:#ERC6909Component-after_update[`++after_update(self, from, recipient, id, amount)++`]
+--
+
+[.contract-index#ERC6909Component-Embeddable-Mixin-Impl]
+.{mixin-impls}
+--
+.ERC6909Impl
+* xref:#ERC6909Component-Embeddable-Impls-ERC6909Impl[`++ERC6909Impl++`]
+* xref:api/introspection.adoc#SRC5Component-Embeddable-Impls-SRC5Impl[`++SRC5Impl++`]
+--
+
+[.contract-index#ERC6909Component-Embeddable-Impls]
+.Embeddable Implementations
+--
+[.sub-index#ERC6909Component-Embeddable-Impls-ERC6909Impl]
+.ERC6909Impl
+* xref:#ERC6909Component-balance_of[`++balance_of(self, owner, id)++`]
+* xref:#ERC6909Component-allowance[`++allowance(self, owner, spender, id)++`]
+* xref:#ERC6909Component-is_operator[`++is_operator(self, owner, spender)++`]
+* xref:#ERC6909Component-transfer[`++transfer(self, receiver, id, amount)++`]
+* xref:#ERC6909Component-transfer_from[`++transfer_from(self, sender, receiver, id, amount)++`]
+* xref:#ERC6909Component-approve[`++approve(self, spender, id, amount)++`]
+* xref:#ERC6909Component-set_operator[`++set_operator(self, spender, approved)++`]
+--
+
+[.contract-index]
+.Internal functions
+--
+.InternalImpl
+* xref:#ERC6909Component-initializer[`++initializer(self)++`]
+* xref:#ERC6909Component-_mint[`++_mint(self, receiver, id, amount)++`]
+* xref:#ERC6909Component-_burn[`++_burn(self, account, id, amount)++`]
+* xref:#ERC6909Component-_update[`++_update(self, sender, receiver, id, amount)++`]
+* xref:#ERC6909Component-_set_operator[`++_set_operator(self, owner, spender, approved)++`]
+* xref:#ERC6909Component-_spend_allowance[`++_spend_allowance(self, owner, spender, id, amount)++`]
+* xref:#ERC6909Component-_approve[`++_approve(self, owner, spender, id, amount)++`]
+* xref:#ERC6909Component-_transfer[`++_transfer(self, sender, receiver, id, amount)++`]
+--
+
+[.contract-index]
+.Events
+--
+.IERC6909
+* xref:#ERC6909Component-Transfer[`++Transfer(caller, sender, receiver, id, amount)++`]
+* xref:#ERC6909Component-Approval[`++Approval(owner, spender, id, amount)++`]
+* xref:#ERC6909Component-OperatorSet[`++OperatorSet(owner, spender, approved)++`]
+--
+
+[#ERC6909Component-Hooks]
+==== Hooks
+
+Hooks are functions which implementations can use to extend the functionality of the component source code.
+Every contract using `ERC6909Component` is expected to provide an implementation of the `ERC6909HooksTrait`.
+For basic token contracts, an empty implementation with no logic must be provided.
+
+TIP: You can use `openzeppelin_token::erc6909::ERC6909HooksEmptyImpl` which is already available as part of
+the library for this purpose.
+
+[.contract-item]
+[[ERC6909Component-before_update]]
+==== `[.contract-item-name]#++before_update++#++(ref self: ContractState, from: ContractAddress, recipient: ContractAddress, id: u256, amount: u256)++` [.item-kind]#hook#
+
+Function executed at the beginning of the xref:#ERC6909Component-_update[_update] function prior to any other logic.
+
+[.contract-item]
+[[ERC6909Component-after_update]]
+==== `[.contract-item-name]#++after_update++#++(ref self: ContractState, from: ContractAddress, recipient: ContractAddress, id: u256, amount: u256)++` [.item-kind]#hook#
+
+Function executed at the end of the xref:#ERC6909Component-_update[_update] function.
+
+==== Embeddable functions
+
+[.contract-item]
+[[ERC6909Component-balance_of]]
+==== `[.contract-item-name]#++balance_of++#++(self: @ContractState, owner: ContractAddress, id: u256) → u256++` [.item-kind]#external#
+
+Returns the amount of `id` tokens owned by `owner`.
+
+[.contract-item]
+[[ERC6909Component-allowance]]
+==== `[.contract-item-name]#++allowance++#++(self: @ContractState, owner: ContractAddress, spender: ContractAddress, id: u256) → u256++` [.item-kind]#external#
+
+Returns the remaining number of `id` tokens that `spender` is allowed to spend on behalf of `owner`
+through <>.
+
+[.contract-item]
+[[ERC6909Component-is_operator]]
+==== `[.contract-item-name]#++is_operator++#++(self: @ContractState, owner: ContractAddress, spender: ContractAddress) → bool++` [.item-kind]#external#
+
+Returns `true` if `spender` is approved by `owner` as an operator over all token IDs.
+
+[.contract-item]
+[[ERC6909Component-transfer]]
+==== `[.contract-item-name]#++transfer++#++(ref self: ContractState, receiver: ContractAddress, id: u256, amount: u256) → bool++` [.item-kind]#external#
+
+Transfers `amount` of token `id` from the caller to `receiver`.
+
+Requirements:
+
+- `receiver` is not the zero address.
+
+Emits a <> event.
+
+[.contract-item]
+[[ERC6909Component-transfer_from]]
+==== `[.contract-item-name]#++transfer_from++#++(ref self: ContractState, sender: ContractAddress, receiver: ContractAddress, id: u256, amount: u256) → bool++` [.item-kind]#external#
+
+Transfers `amount` of token `id` from `sender` to `receiver`, deducting from the caller's allowance
+for `sender` and `id` if applicable.
+
+Requirements:
+
+- Caller is either `sender`, an operator for `sender`, or has sufficient allowance.
+- `sender` is not the zero address.
+- `receiver` is not the zero address.
+- `sender` must have at least a balance of `amount` for token `id`.
+
+Emits a <> event.
+
+[.contract-item]
+[[ERC6909Component-approve]]
+==== `[.contract-item-name]#++approve++#++(ref self: ContractState, spender: ContractAddress, id: u256, amount: u256) → bool++` [.item-kind]#external#
+
+Sets `amount` as the allowance of `spender` over the caller's tokens of token `id`.
+
+Requirements:
+
+- `spender` is not the zero address.
+
+Emits an <> event.
+
+[.contract-item]
+[[ERC6909Component-set_operator]]
+==== `[.contract-item-name]#++set_operator++#++(ref self: ContractState, spender: ContractAddress, approved: bool) → bool++` [.item-kind]#external#
+
+Sets or unsets `spender` as an operator for the caller, granting or revoking permission to transfer
+any of the caller's token IDs.
+
+Requirements:
+
+- `spender` is not the zero address.
+
+Emits an <> event.
+
+==== Internal functions
+
+[.contract-item]
+[[ERC6909Component-initializer]]
+==== `[.contract-item-name]#++initializer++#++(ref self: ContractState)++` [.item-kind]#internal#
+
+Initializes the contract by registering the IERC6909 interface ID as supported through introspection.
+
+This should only be used inside the contract's constructor.
+
+[.contract-item]
+[[ERC6909Component-_mint]]
+==== `[.contract-item-name]#++_mint++#++(ref self: ContractState, receiver: ContractAddress, id: u256, amount: u256)++` [.item-kind]#internal#
+
+Mints `amount` tokens of token ID `id` to `receiver`.
+
+Requirements:
+
+- `receiver` is not the zero address.
+
+Emits a <> event with `sender` set to the zero address.
+
+[.contract-item]
+[[ERC6909Component-_burn]]
+==== `[.contract-item-name]#++_burn++#++(ref self: ContractState, account: ContractAddress, id: u256, amount: u256)++` [.item-kind]#internal#
+
+Destroys `amount` tokens of ID `id` from `account`.
+
+Requirements:
+
+- `account` is not the zero address.
+- `account` must have at least a balance of `amount` for token `id`.
+
+Emits a <> event with `receiver` set to the zero address.
+
+[.contract-item]
+[[ERC6909Component-_update]]
+==== `[.contract-item-name]#++_update++#++(ref self: ContractState, sender: ContractAddress, receiver: ContractAddress, id: u256, amount: u256)++` [.item-kind]#internal#
+
+Transfers `amount` tokens of ID `id` from `sender` to `receiver`, or alternatively mints (or burns)
+if `sender` (or `receiver`) is the zero address.
+
+This function can be extended using the xref:#ERC6909Component-ERC6909HooksTrait[ERC6909HooksTrait], to add
+functionality before and/or after the transfer, mint, or burn.
+
+The implementation does not track per-token total supply; this logic is delegated to extensions.
+
+Emits a <> event.
+
+[.contract-item]
+[[ERC6909Component-_set_operator]]
+==== `[.contract-item-name]#++_set_operator++#++(ref self: ContractState, owner: ContractAddress, spender: ContractAddress, approved: bool)++` [.item-kind]#internal#
+
+Sets or unsets `spender` as an operator for `owner`.
+
+Emits an <> event.
+
+[.contract-item]
+[[ERC6909Component-_spend_allowance]]
+==== `[.contract-item-name]#++_spend_allowance++#++(ref self: ContractState, owner: ContractAddress, spender: ContractAddress, id: u256, amount: u256)++` [.item-kind]#internal#
+
+Updates `owner`'s allowance for `spender` and token `id` based on `amount` spent.
+
+Spenders that have been set as operators for `owner` are not subject to allowance restrictions.
+If the current allowance is set to the maximum value, it is treated as infinite and is not reduced.
+
+Requirements:
+
+- If not infinite and not an operator, the allowance must be at least `amount`.
+
+[.contract-item]
+[[ERC6909Component-_approve]]
+==== `[.contract-item-name]#++_approve++#++(ref self: ContractState, owner: ContractAddress, spender: ContractAddress, id: u256, amount: u256)++` [.item-kind]#internal#
+
+Internal method that sets `amount` as the allowance of `spender` over `owner`'s tokens of token `id`.
+
+Requirements:
+
+- `owner` is not the zero address.
+- `spender` is not the zero address.
+
+Emits an <> event.
+
+[.contract-item]
+[[ERC6909Component-_transfer]]
+==== `[.contract-item-name]#++_transfer++#++(ref self: ContractState, sender: ContractAddress, receiver: ContractAddress, id: u256, amount: u256)++` [.item-kind]#internal#
+
+Internal method that moves `amount` tokens of ID `id` from `sender` to `receiver`.
+
+Requirements:
+
+- `sender` is not the zero address.
+- `receiver` is not the zero address.
+- `sender` must have at least a balance of `amount` for token `id`.
+
+Emits a <> event.
+
+==== Events
+
+[.contract-item]
+[[ERC6909Component-Transfer]]
+==== `[.contract-item-name]#++Transfer++#++(caller: ContractAddress, sender: ContractAddress, receiver: ContractAddress, id: u256, amount: u256)++` [.item-kind]#event#
+
+See <>.
+
+[.contract-item]
+[[ERC6909Component-Approval]]
+==== `[.contract-item-name]#++Approval++#++(owner: ContractAddress, spender: ContractAddress, id: u256, amount: u256)++` [.item-kind]#event#
+
+See <>.
+
+[.contract-item]
+[[ERC6909Component-OperatorSet]]
+==== `[.contract-item-name]#++OperatorSet++#++(owner: ContractAddress, spender: ContractAddress, approved: bool)++` [.item-kind]#event#
+
+See <>.
+
+== Extensions
+
+[.contract]
+[[ERC6909ContentURIComponent]]
+=== `++ERC6909ContentURIComponent++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v3.0.0-alpha.3/packages/token/src/erc6909/extensions/erc6909_content_uri.cairo[{github-icon},role=heading-link]
+
+[.hljs-theme-dark]
+```cairo
+use openzeppelin_token::erc6909::extensions::ERC6909ContentURIComponent;
+```
+
+ERC6909ContentURI component implementing <>.
+
+NOTE: Implementing xref:#ERC6909Component[ERC6909Component] is a requirement for this component to be implemented.
+
+[.contract-index#ERC6909ContentURIComponent-Embeddable-Impls]
+.Embeddable Implementations
+--
+[.sub-index#ERC6909ContentURIComponent-Embeddable-Impls-ERC6909ContentURIImpl]
+.ERC6909ContentURIImpl
+* xref:#ERC6909ContentURIComponent-contract_uri[`++contract_uri(self)++`]
+* xref:#ERC6909ContentURIComponent-token_uri[`++token_uri(self, id)++`]
+--
+
+[.contract-index]
+.Internal functions
+--
+.InternalImpl
+* xref:#ERC6909ContentURIComponent-initializer[`++initializer(self, contract_uri)++`]
+--
+
+==== Embeddable functions
+
+[.contract-item]
+[[ERC6909ContentURIComponent-contract_uri]]
+==== `[.contract-item-name]#++contract_uri++#++(self: @ContractState) → ByteArray++` [.item-kind]#external#
+
+Returns the contract-level URI.
+
+[.contract-item]
+[[ERC6909ContentURIComponent-token_uri]]
+==== `[.contract-item-name]#++token_uri++#++(self: @ContractState, id: u256) → ByteArray++` [.item-kind]#external#
+
+Returns the token-level URI for token `id`.
+
+If a contract URI is set, the resulting URI for each token will be the concatenation of the contract URI
+and the token ID. For example, the contract URI pass:[https://token-cdn-domain/] would be
+returned as pass:[https://token-cdn-domain/123] for token ID 123.
+
+If the contract URI is not set, the return value will be an empty ByteArray.
+
+==== Internal functions
+
+[.contract-item]
+[[ERC6909ContentURIComponent-initializer]]
+==== `[.contract-item-name]#++initializer++#++(ref self: ContractState, contract_uri: ByteArray)++` [.item-kind]#internal#
+
+Initializes the component by setting the contract-level URI to `contract_uri` and registering the
+IERC6909ContentUri interface ID as supported through introspection.
+
+This should be used inside the contract's constructor.
+
+[.contract]
+[[ERC6909MetadataComponent]]
+=== `++ERC6909MetadataComponent++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v3.0.0-alpha.3/packages/token/src/erc6909/extensions/erc6909_metadata.cairo[{github-icon},role=heading-link]
+
+[.hljs-theme-dark]
+```cairo
+use openzeppelin_token::erc6909::extensions::ERC6909MetadataComponent;
+```
+
+ERC6909Metadata component implementing <>.
+
+This extension allows contracts to associate name, symbol, and decimals metadata with each token ID.
+
+NOTE: Implementing xref:#ERC6909Component[ERC6909Component] is a requirement for this component to be implemented.
+To properly initialize metadata, this extension expects its internal helper
+xref:#ERC6909MetadataComponent-_update_token_metadata[_update_token_metadata] to be used from the ERC6909 hooks
+during mints.
+
+[.contract-index#ERC6909MetadataComponent-Embeddable-Impls]
+.Embeddable Implementations
+--
+[.sub-index#ERC6909MetadataComponent-Embeddable-Impls-ERC6909MetadataImpl]
+.ERC6909MetadataImpl
+* xref:#ERC6909MetadataComponent-name[`++name(self, id)++`]
+* xref:#ERC6909MetadataComponent-symbol[`++symbol(self, id)++`]
+* xref:#ERC6909MetadataComponent-decimals[`++decimals(self, id)++`]
+--
+
+[.contract-index]
+.Internal functions
+--
+.InternalImpl
+* xref:#ERC6909MetadataComponent-initializer[`++initializer(self)++`]
+* xref:#ERC6909MetadataComponent-_update_token_metadata[`++_update_token_metadata(self, sender, id, name, symbol, decimals)++`]
+* xref:#ERC6909MetadataComponent-_token_metadata_exists[`++_token_metadata_exists(self, id)++`]
+* xref:#ERC6909MetadataComponent-_set_token_metadata[`++_set_token_metadata(self, id, name, symbol, decimals)++`]
+* xref:#ERC6909MetadataComponent-_set_token_name[`++_set_token_name(self, id, name)++`]
+* xref:#ERC6909MetadataComponent-_set_token_symbol[`++_set_token_symbol(self, id, symbol)++`]
+* xref:#ERC6909MetadataComponent-_set_token_decimals[`++_set_token_decimals(self, id, decimals)++`]
+--
+
+==== Embeddable functions
+
+[.contract-item]
+[[ERC6909MetadataComponent-name]]
+==== `[.contract-item-name]#++name++#++(self: @ContractState, id: u256) → ByteArray++` [.item-kind]#external#
+
+Returns the name of token `id`.
+
+[.contract-item]
+[[ERC6909MetadataComponent-symbol]]
+==== `[.contract-item-name]#++symbol++#++(self: @ContractState, id: u256) → ByteArray++` [.item-kind]#external#
+
+Returns the symbol of token `id`.
+
+[.contract-item]
+[[ERC6909MetadataComponent-decimals]]
+==== `[.contract-item-name]#++decimals++#++(self: @ContractState, id: u256) → u8++` [.item-kind]#external#
+
+Returns the number of decimals of token `id`.
+
+==== Internal functions
+
+[.contract-item]
+[[ERC6909MetadataComponent-initializer]]
+==== `[.contract-item-name]#++initializer++#++(ref self: ContractState)++` [.item-kind]#internal#
+
+Initializes the component by registering the IERC6909Metadata interface ID as supported
+through introspection.
+
+This should be used inside the contract's constructor.
+
+[.contract-item]
+[[ERC6909MetadataComponent-_update_token_metadata]]
+==== `[.contract-item-name]#++_update_token_metadata++#++(ref self: ContractState, sender: ContractAddress, id: u256, name: ByteArray, symbol: ByteArray, decimals: u8)++` [.item-kind]#internal#
+
+Updates the metadata of token `id` when it is first minted.
+
+If `sender` is the zero address (i.e. this call happens during a mint) and `id` does not yet
+have metadata, this function stores the provided `name`, `symbol`, and `decimals`.
+
+This helper is intended to be called from the ERC6909 hooks during mint operations.
+
+[.contract-item]
+[[ERC6909MetadataComponent-_token_metadata_exists]]
+==== `[.contract-item-name]#++_token_metadata_exists++#++(self: @ContractState, id: u256) → bool++` [.item-kind]#internal#
+
+Returns `true` if token `id` has metadata associated with it at the time of minting.
+
+[.contract-item]
+[[ERC6909MetadataComponent-_set_token_metadata]]
+==== `[.contract-item-name]#++_set_token_metadata++#++(ref self: ContractState, id: u256, name: ByteArray, symbol: ByteArray, decimals: u8)++` [.item-kind]#internal#
+
+Updates the stored metadata for token `id` with the provided `name`, `symbol`, and `decimals`.
+
+[.contract-item]
+[[ERC6909MetadataComponent-_set_token_name]]
+==== `[.contract-item-name]#++_set_token_name++#++(ref self: ContractState, id: u256, name: ByteArray)++` [.item-kind]#internal#
+
+Sets the name for token `id`.
+
+[.contract-item]
+[[ERC6909MetadataComponent-_set_token_symbol]]
+==== `[.contract-item-name]#++_set_token_symbol++#++(ref self: ContractState, id: u256, symbol: ByteArray)++` [.item-kind]#internal#
+
+Sets the symbol for token `id`.
+
+[.contract-item]
+[[ERC6909MetadataComponent-_set_token_decimals]]
+==== `[.contract-item-name]#++_set_token_decimals++#++(ref self: ContractState, id: u256, decimals: u8)++` [.item-kind]#internal#
+
+Sets the decimals for token `id`.
+
+[.contract]
+[[ERC6909TokenSupplyComponent]]
+=== `++ERC6909TokenSupplyComponent++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v3.0.0-alpha.3/packages/token/src/erc6909/extensions/erc6909_token_supply.cairo[{github-icon},role=heading-link]
+
+[.hljs-theme-dark]
+```cairo
+use openzeppelin_token::erc6909::extensions::ERC6909TokenSupplyComponent;
+```
+
+ERC6909TokenSupply component implementing <>.
+
+This extension allows contracts to track the total supply for each token ID.
+
+NOTE: Implementing xref:#ERC6909Component[ERC6909Component] is a requirement for this component to be implemented.
+To properly track total supply, this extension expects its helper
+xref:#ERC6909TokenSupplyComponent-_update_token_supply[_update_token_supply] to be used from the ERC6909 hooks
+during mints and burns.
+
+[.contract-index#ERC6909TokenSupplyComponent-Embeddable-Impls]
+.Embeddable Implementations
+--
+[.sub-index#ERC6909TokenSupplyComponent-Embeddable-Impls-ERC6909TokenSupplyImpl]
+.ERC6909TokenSupplyImpl
+* xref:#ERC6909TokenSupplyComponent-total_supply[`++total_supply(self, id)++`]
+--
+
+[.contract-index]
+.Internal functions
+--
+.InternalImpl
+* xref:#ERC6909TokenSupplyComponent-initializer[`++initializer(self)++`]
+* xref:#ERC6909TokenSupplyComponent-_update_token_supply[`++_update_token_supply(self, sender, receiver, id, amount)++`]
+--
+
+[#ERC6909TokenSupplyComponent-Embeddable-functions]
+==== Embeddable functions
+
+[.contract-item]
+[[ERC6909TokenSupplyComponent-total_supply]]
+==== `[.contract-item-name]#++total_supply++#++(self: @ContractState, id: u256) → u256++` [.item-kind]#external#
+
+Returns the total supply of token `id`.
+
+[#ERC6909TokenSupplyComponent-Internal-functions]
+==== Internal functions
+
+[.contract-item]
+[[ERC6909TokenSupplyComponent-initializer]]
+==== `[.contract-item-name]#++initializer++#++(ref self: ContractState)++` [.item-kind]#internal#
+
+Initializes the component by registering the IERC6909TokenSupply interface ID as supported
+through introspection.
+
+This should be used inside the contract's constructor.
+
+[.contract-item]
+[[ERC6909TokenSupplyComponent-_update_token_supply]]
+==== `[.contract-item-name]#++_update_token_supply++#++(ref self: ContractState, sender: ContractAddress, receiver: ContractAddress, id: u256, amount: u256)++` [.item-kind]#internal#
+
+Updates the total supply of token `id`.
+
+When `sender` is the zero address, `amount` is added to the total supply (mint).
+When `receiver` is the zero address, `amount` is subtracted from the total supply (burn).
+
+This helper is intended to be called from the ERC6909 hooks during mint and burn operations.
diff --git a/docs/modules/ROOT/pages/erc6909.adoc b/docs/modules/ROOT/pages/erc6909.adoc
new file mode 100644
index 000000000..0a302f788
--- /dev/null
+++ b/docs/modules/ROOT/pages/erc6909.adoc
@@ -0,0 +1,337 @@
+:eip6909: https://eips.ethereum.org/EIPS/eip-6909[EIP-6909]
+:fungibility-agnostic: https://docs.openzeppelin.com/contracts/5.x/tokens#different-kinds-of-tokens[fungibility-agnostic]
+
+= ERC6909
+
+The ERC6909 minimal multi-token standard is a specification for {fungibility-agnostic} token contracts that manage multiple token IDs in a single contract. Compared to ERC1155, it removes batch operations and transfer callbacks, and introduces a hybrid allowance–operator approval model for more granular control over approvals.
+The ERC6909 library provides an approximation of {eip6909} in Cairo for Starknet.
+
+== Usage
+
+:mint-api: xref:api/erc6909.adoc#ERC6909Component-_mint[_mint]
+:burn-api: xref:api/erc6909.adoc#ERC6909Component-_burn[_burn]
+:transfer-api: xref:api/erc6909.adoc#IERC6909-transfer[transfer]
+:transfer-from-api: xref:api/erc6909.adoc#IERC6909-transfer_from[transfer_from]
+:set-operator-api: xref:api/erc6909.adoc#IERC6909-set_operator[set_operator]
+
+Using Contracts for Cairo, constructing an ERC6909 contract requires integrating both `ERC6909Component` and `SRC5Component`.
+The contract should also set up the constructor to register interface support and optionally mint initial balances.
+Here's an example of a basic contract:
+
+[,cairo]
+----
+#[starknet::contract]
+mod MyERC6909 {
+ use openzeppelin_introspection::src5::SRC5Component;
+ use openzeppelin_token::erc6909::{ERC6909Component, ERC6909HooksEmptyImpl};
+ use starknet::ContractAddress;
+
+ component!(path: ERC6909Component, storage: erc6909, event: ERC6909Event);
+ component!(path: SRC5Component, storage: src5, event: SRC5Event);
+
+ #[abi(embed_v0)]
+ impl ERC6909Impl = ERC6909Component::ERC6909Impl;
+ impl ERC6909InternalImpl = ERC6909Component::InternalImpl;
+
+ #[storage]
+ struct Storage {
+ #[substorage(v0)]
+ erc6909: ERC6909Component::Storage,
+ #[substorage(v0)]
+ src5: SRC5Component::Storage
+ }
+
+ #[event]
+ #[derive(Drop, starknet::Event)]
+ enum Event {
+ #[flat]
+ ERC6909Event: ERC6909Component::Event,
+ #[flat]
+ SRC5Event: SRC5Component::Event
+ }
+
+ #[constructor]
+ fn constructor(
+ ref self: ContractState,
+ recipient: ContractAddress
+ id: u256,
+ amount: u256,
+ ) {
+ self.erc6909.initializer();
+ self.erc6909._mint(recipient, id, amount);
+
+ }
+}
+----
+
+Once deployed, this contract exposes the full {transfer-api}, {transfer-from-api}, and {set-operator-api} entrypoints defined in {eip6909}, supporting both token ID allowances and global operators for an account.
+
+== Interface
+
+:compatibility: xref:/erc6909.adoc#erc6909_compatibility[ERC6909 Compatibility]
+:isrc5-interface: xref:/api/introspection.adoc#ISRC5[ISRC5]
+:ierc6909-interface: xref:/api/erc6909.adoc#IERC6909[IERC6909]
+:ierc6909-metadata-interface: xref:/api/erc6909.adoc#IERC6909Metadata[IERC6909Metadata]
+:ierc6909-content-uri-interface: xref:/api/erc6909.adoc#IERC6909ContentUri[IERC6909ContentUri]
+:ierc6909-token-supply-interface: xref:/api/erc6909.adoc#IERC6909TokenSupply[IERC6909TokenSupply]
+:erc6909-component: xref:/api/erc6909.adoc#ERC6909Component[ERC6909Component]
+
+The following interface represents a convenient ABI that mirrors the full surface of the Contracts for Cairo
+{erc6909-component} together with its optional extensions. It includes the {ierc6909-interface} minimal multi-token
+interface, the optional {ierc6909-content-uri-interface}, {ierc6909-metadata-interface}, and
+{ierc6909-token-supply-interface} interfaces, as well as {isrc5-interface} for introspection.
+
+[,cairo]
+----
+#[starknet::interface]
+pub trait ERC6909ABI {
+ // IERC6909
+ fn balance_of(owner: ContractAddress, id: u256) -> u256;
+ fn allowance(owner: ContractAddress, spender: ContractAddress, id: u256) -> u256;
+ fn is_operator(owner: ContractAddress, spender: ContractAddress) -> bool;
+ fn transfer(receiver: ContractAddress, id: u256, amount: u256) -> bool;
+ fn transfer_from(
+ sender: ContractAddress,
+ receiver: ContractAddress,
+ id: u256,
+ amount: u256
+ ) -> bool;
+ fn approve(spender: ContractAddress, id: u256, amount: u256) -> bool;
+ fn set_operator(spender: ContractAddress, approved: bool) -> bool;
+
+ // IERC6909ContentUri
+ fn contract_uri() -> ByteArray;
+ fn token_uri(id: u256) -> ByteArray;
+
+ // IERC6909Metadata
+ fn name(id: u256) -> ByteArray;
+ fn symbol(id: u256) -> ByteArray;
+ fn decimals(id: u256) -> u8;
+
+ // IERC6909TokenSupply
+ fn total_supply(id: u256) -> u256;
+
+ // ISRC5
+ fn supports_interface(interface_id: felt252) -> bool;
+}
+----
+
+NOTE: This ABI is provided as a convenience example that groups the standard and optional interfaces implemented by the
+components. Your contracts are free to expose a subset of these functions depending on which extensions you integrate.
+
+[#erc6909_compatibility]
+== ERC6909 compatibility
+
+:src5-api: xref:introspection.adoc#src5[SRC5]
+:introspection: xref:introspection.adoc[Introspection]
+:eip6909-spec: https://eips.ethereum.org/EIPS/eip-6909[EIP-6909]
+
+Although Starknet is not EVM compatible, this implementation aims to be as close as possible to the ERC6909 standard.
+Some notable differences are:
+
+* Interface detection is implemented through the {src5-api} introspection standard on Starknet.
+ Components hardcode their interface IDs according to Cairo selector calculations and register them via SRC5.
+ See the {introspection} docs for details.
+* Strings and URIs are represented as `ByteArray` instead of Solidity's `string`.
+* Infinite allowances follow the {eip6909-spec} convention by assigning the maximum possible value of the u256 type
+* Spenders marked as operators for an owner are not subject to allowance restrictions.
+ In that case, `transfer_from` will bypass allowance accounting and only enforce balance constraints, in line with
+ the {eip6909-spec} semantics.
+
+== Hooks and extensions
+
+:hooks-api: xref:api/erc6909.adoc#ERC6909Component-ERC6909HooksTrait[ERC6909HooksTrait]
+:before-update-api: xref:api/erc6909.adoc#ERC6909Component-before_update[before_update]
+:after-update-api: xref:api/erc6909.adoc#ERC6909Component-after_update[after_update]
+
+The {erc6909-component} exposes a hook trait, {hooks-api}, that allows extending its core behavior without modifying
+the component code itself. Every contract using `ERC6909Component` is expected to provide an implementation of this
+trait, even if empty, typically using `ERC6909HooksEmptyImpl` for basic presets.
+
+Hooks are invoked by the internal `_update` helper whenever tokens are transferred, minted, or burned:
+
+* {before-update-api} is executed before balances are modified.
+* {after-update-api} is executed after balances are modified and events have been emitted.
+
+TIP: Extensions such as `ERC6909TokenSupplyComponent` and `ERC6909MetadataComponent` are designed to be called from
+these hooks.
+
+=== ERC6909ContentURI
+
+:erc6909-contenturi-component: xref:/api/erc6909.adoc#ERC6909ContentURIComponent[ERC6909ContentURIComponent]
+:contract-uri-api: xref:/api/erc6909.adoc#IERC6909ContentUri-contract_uri[contract_uri]
+:token-uri-api: xref:/api/erc6909.adoc#IERC6909ContentUri-token_uri[token_uri]
+
+The {erc6909-contenturi-component} implements {ierc6909-content-uri-interface} and provides contract-level and
+token-level URIs.
+
+* {contract-uri-api} returns the base contract URI.
+* {token-uri-api} returns the concatenation of the contract URI and the token ID, or an empty `ByteArray` if no
+ contract URI is set.
+
+A typical integration calls the component's `initializer` in the constructor:
+
+[,cairo]
+----
+#[starknet::contract]
+mod MyERC6909WithURI {
+ use openzeppelin_introspection::src5::SRC5Component;
+ use openzeppelin_token::erc6909::{
+ ERC6909Component, ERC6909ContentURIComponent, ERC6909HooksEmptyImpl
+ };
+ use starknet::ContractAddress;
+
+ component!(path: ERC6909Component, storage: erc6909, event: ERC6909Event);
+ component!(path: ERC6909ContentURIComponent, storage: erc6909_content_uri, event: ERC6909ContentURIEvent);
+ component!(path: SRC5Component, storage: src5, event: SRC5Event);
+
+ #[abi(embed_v0)]
+ impl ERC6909Impl = ERC6909Component::ERC6909Impl;
+ #[abi(embed_v0)]
+ impl ERC6909ContentURIImpl = ERC6909ContentURIComponent::ERC6909ContentURIImpl;
+ impl ERC6909InternalImpl = ERC6909Component::InternalImpl;
+ impl ERC6909ContentURIInternalImpl = ERC6909ContentURIComponent::InternalImpl;
+
+ #[storage]
+ struct Storage {
+ #[substorage(v0)]
+ erc6909: ERC6909Component::Storage,
+ #[substorage(v0)]
+ erc6909_content_uri: ERC6909ContentURIComponent::Storage,
+ #[substorage(v0)]
+ src5: SRC5Component::Storage
+ }
+
+ #[event]
+ #[derive(Drop, starknet::Event)]
+ enum Event {
+ #[flat]
+ ERC6909Event: ERC6909Component::Event,
+ #[flat]
+ ERC6909ContentURIEvent: ERC6909ContentURIComponent::Event,
+ #[flat]
+ SRC5Event: SRC5Component::Event
+ }
+
+ #[constructor]
+ fn constructor(
+ ref self: ContractState,
+ contract_uri: ByteArray,
+ recipient: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ self.erc6909.initializer();
+ self.erc6909_content_uri.initializer(contract_uri);
+ self.erc6909._mint(recipient, id, amount);
+ }
+}
+----
+
+=== ERC6909Metadata
+
+:erc6909-metadata-component: xref:/api/erc6909.adoc#ERC6909MetadataComponent[ERC6909MetadataComponent]
+:metadata-name-api: xref:/api/erc6909.adoc#IERC6909Metadata-name[name]
+:metadata-symbol-api: xref:/api/erc6909.adoc#IERC6909Metadata-symbol[symbol]
+:metadata-decimals-api: xref:/api/erc6909.adoc#IERC6909Metadata-decimals[decimals]
+:update-token-metadata-api: xref:/api/erc6909.adoc#ERC6909MetadataComponent-_update_token_metadata[_update_token_metadata]
+
+The {erc6909-metadata-component} implements {ierc6909-metadata-interface} and allows associating metadata with each
+token ID:
+
+* {metadata-name-api} returns the token name.
+* {metadata-symbol-api} returns the token symbol.
+* {metadata-decimals-api} returns the number of decimals for that token ID.
+
+The extension expects its helper {update-token-metadata-api} to be called from the ERC6909 hooks.
+A simple pattern is to call it from `before_update`, passing the `from` address. The helper will detect mints
+(when `from` is the zero address) and only set metadata for token IDs that do not already have it.
+
+[,cairo]
+----
+use openzeppelin_token::erc6909::{ERC6909Component, ERC6909MetadataComponent};
+use starknet::ContractAddress;
+
+pub impl ERC6909HooksImpl of ERC6909Component::ERC6909HooksTrait {
+ fn before_update(
+ ref self: ContractState,
+ from: ContractAddress,
+ recipient: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ // Example: assign default metadata on first mint of each ID
+ let name = "MyERC6909Token";
+ let symbol = "M6909";
+ let decimals: u8 = 18_u8;
+
+ self
+ .erc6909_metadata
+ ._update_token_metadata(from, id, name, symbol, decimals);
+ }
+
+ fn after_update(
+ ref self: ContractState,
+ from: ContractAddress,
+ recipient: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {}
+}
+----
+
+Don't forget to:
+
+* Add `ERC6909MetadataComponent` as a component in your contract.
+* Call its `initializer` in the constructor so that the `IERC6909Metadata` interface ID is registered via SRC5.
+
+=== ERC6909TokenSupply
+
+:erc6909-supply-component: xref:/api/erc6909.adoc#ERC6909TokenSupplyComponent[ERC6909TokenSupplyComponent]
+:total-supply-api: xref:/api/erc6909.adoc#IERC6909TokenSupply-total_supply[total_supply]
+:update-token-supply-api: xref:/api/erc6909.adoc#ERC6909TokenSupplyComponent-_update_token_supply[_update_token_supply]
+
+The {erc6909-supply-component} implements {ierc6909-token-supply-interface} and tracks the total supply per token ID.
+
+* {total-supply-api} returns the total supply of a given token ID.
+
+The helper {update-token-supply-api} is designed to be called from ERC6909 hooks during mints and burns:
+
+* When `sender` is the zero address, `amount` is added to the total supply.
+* When `receiver` is the zero address, `amount` is subtracted from the total supply.
+
+[,cairo]
+----
+use openzeppelin_token::erc6909::{ERC6909Component, ERC6909TokenSupplyComponent};
+use starknet::ContractAddress;
+
+pub impl ERC6909HooksImpl of ERC6909Component::ERC6909HooksTrait {
+ fn before_update(
+ ref self: ContractState,
+ from: ContractAddress,
+ recipient: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ self
+ .erc6909_token_supply
+ ._update_token_supply(from, recipient, id, amount);
+ }
+
+ fn after_update(
+ ref self: ContractState,
+ from: ContractAddress,
+ recipient: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {}
+}
+----
+
+As with other extensions:
+
+* Add `ERC6909TokenSupplyComponent` as a component in your contract.
+* Call its `initializer` during construction to register `IERC6909TokenSupply` support via SRC5.
+
+By composing `ERC6909Component` with these extensions and your own hook logic, you can build rich multi-token systems
+with per-ID metadata, URIs, and supply tracking while keeping the core transfer logic minimal and reusable.
\ No newline at end of file
diff --git a/packages/interfaces/src/lib.cairo b/packages/interfaces/src/lib.cairo
index 30ccb7108..8c381e131 100644
--- a/packages/interfaces/src/lib.cairo
+++ b/packages/interfaces/src/lib.cairo
@@ -23,7 +23,7 @@ pub use security::{initializable, pausable};
// Token
pub mod token;
-pub use token::{erc1155, erc20, erc2981, erc4626, erc721};
+pub use token::{erc1155, erc20, erc2981, erc4626, erc6909, erc721};
// Upgrades
pub mod upgrades;
diff --git a/packages/interfaces/src/token.cairo b/packages/interfaces/src/token.cairo
index a9654141f..523aef8f8 100644
--- a/packages/interfaces/src/token.cairo
+++ b/packages/interfaces/src/token.cairo
@@ -2,4 +2,5 @@ pub mod erc1155;
pub mod erc20;
pub mod erc2981;
pub mod erc4626;
+pub mod erc6909;
pub mod erc721;
diff --git a/packages/interfaces/src/token/erc6909.cairo b/packages/interfaces/src/token/erc6909.cairo
new file mode 100644
index 000000000..5744978c4
--- /dev/null
+++ b/packages/interfaces/src/token/erc6909.cairo
@@ -0,0 +1,99 @@
+// SPDX-License-Identifier: MIT
+
+use starknet::ContractAddress;
+
+pub const IERC6909_ID: felt252 = 0xd5aa138060489fd9c4592f77a16011cc5615ce4d292ee1f7873ae65c43b6bb;
+pub const IERC6909_METADATA_ID: felt252 =
+ 0x19aa0b778d120d5294054319458ee8886514766411c50dceddd9463712d6011;
+pub const IERC6909_TOKEN_SUPPLY_ID: felt252 =
+ 0x3a632c15cb93b574eb9166de70521abbeab5c2eb4fdab9930729bba8658c41;
+pub const IERC6909_CONTENT_URI_ID: felt252 =
+ 0x356efd8b40a01c1525c7d0ecafbe3b82a47df564fdd496727effe6336526f05;
+
+#[starknet::interface]
+pub trait IERC6909 {
+ fn balance_of(self: @TState, owner: ContractAddress, id: u256) -> u256;
+ fn allowance(self: @TState, owner: ContractAddress, spender: ContractAddress, id: u256) -> u256;
+ fn is_operator(self: @TState, owner: ContractAddress, spender: ContractAddress) -> bool;
+ fn transfer(ref self: TState, receiver: ContractAddress, id: u256, amount: u256) -> bool;
+ fn transfer_from(
+ ref self: TState,
+ sender: ContractAddress,
+ receiver: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) -> bool;
+ fn approve(ref self: TState, spender: ContractAddress, id: u256, amount: u256) -> bool;
+ fn set_operator(ref self: TState, spender: ContractAddress, approved: bool) -> bool;
+}
+
+
+#[starknet::interface]
+pub trait ERC6909ABI {
+ // IERC6909
+ fn balance_of(self: @TState, owner: ContractAddress, id: u256) -> u256;
+ fn allowance(self: @TState, owner: ContractAddress, spender: ContractAddress, id: u256) -> u256;
+ fn is_operator(self: @TState, owner: ContractAddress, spender: ContractAddress) -> bool;
+ fn transfer(ref self: TState, receiver: ContractAddress, id: u256, amount: u256) -> bool;
+ fn transfer_from(
+ ref self: TState,
+ sender: ContractAddress,
+ receiver: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) -> bool;
+ fn approve(ref self: TState, spender: ContractAddress, id: u256, amount: u256) -> bool;
+ fn set_operator(ref self: TState, spender: ContractAddress, approved: bool) -> bool;
+
+ // ISRC5
+ fn supports_interface(self: @TState, interface_id: felt252) -> bool;
+}
+
+//
+// ERC6909Metadata
+//
+
+#[starknet::interface]
+pub trait IERC6909Metadata {
+ fn name(self: @TState, id: u256) -> ByteArray;
+ fn symbol(self: @TState, id: u256) -> ByteArray;
+ fn decimals(self: @TState, id: u256) -> u8;
+}
+
+#[starknet::interface]
+pub trait ERC6909MetadataABI {
+ fn name(self: @TState, id: u256) -> ByteArray;
+ fn symbol(self: @TState, id: u256) -> ByteArray;
+ fn decimals(self: @TState, id: u256) -> u8;
+}
+
+//
+// ERC6909TokenSupply
+//
+
+#[starknet::interface]
+pub trait IERC6909TokenSupply {
+ fn total_supply(self: @TState, id: u256) -> u256;
+}
+
+#[starknet::interface]
+pub trait ERC6909TokenSupplyABI {
+ fn total_supply(self: @TState, id: u256) -> u256;
+}
+
+//
+// ERC6909ContentURI
+//
+
+#[starknet::interface]
+pub trait IERC6909ContentUri {
+ fn contract_uri(self: @TState) -> ByteArray;
+ fn token_uri(self: @TState, id: u256) -> ByteArray;
+}
+
+#[starknet::interface]
+pub trait IERC6909ContentUriABI {
+ fn contract_uri(self: @TState) -> ByteArray;
+ fn token_uri(self: @TState, id: u256) -> ByteArray;
+}
+
diff --git a/packages/macros/src/attribute/with_components/components.rs b/packages/macros/src/attribute/with_components/components.rs
index 990d673b6..34f6c9148 100644
--- a/packages/macros/src/attribute/with_components/components.rs
+++ b/packages/macros/src/attribute/with_components/components.rs
@@ -22,6 +22,10 @@ pub enum AllowedComponents {
ERC1155Receiver,
ERC2981,
ERC4626,
+ ERC6909,
+ ERC6909ContentURI,
+ ERC6909Metadata,
+ ERC6909TokenSupply,
Upgradeable,
Nonces,
Multisig,
@@ -60,6 +64,10 @@ impl AllowedComponents {
"ERC1155Receiver" => Ok(AllowedComponents::ERC1155Receiver),
"ERC2981" => Ok(AllowedComponents::ERC2981),
"ERC4626" => Ok(AllowedComponents::ERC4626),
+ "ERC6909" => Ok(AllowedComponents::ERC6909),
+ "ERC6909ContentURI" => Ok(AllowedComponents::ERC6909ContentURI),
+ "ERC6909Metadata" => Ok(AllowedComponents::ERC6909Metadata),
+ "ERC6909TokenSupply" => Ok(AllowedComponents::ERC6909TokenSupply),
"Upgradeable" => Ok(AllowedComponents::Upgradeable),
"Nonces" => Ok(AllowedComponents::Nonces),
"Multisig" => Ok(AllowedComponents::Multisig),
@@ -249,6 +257,42 @@ impl AllowedComponents {
has_immutable_config: true,
internal_impls: vec!["InternalImpl"],
},
+ AllowedComponents::ERC6909 => ComponentInfo {
+ name: "ERC6909Component",
+ path: "openzeppelin_token::erc6909::ERC6909Component",
+ storage: "erc6909",
+ event: "ERC6909Event",
+ has_initializer: true,
+ has_immutable_config: false,
+ internal_impls: vec!["InternalImpl"],
+ },
+ AllowedComponents::ERC6909ContentURI => ComponentInfo {
+ name: "ERC6909ContentURIComponent",
+ path: "openzeppelin_token::erc6909::extensions::ERC6909ContentURIComponent",
+ storage: "erc6909_content_uri",
+ event: "ERC6909ContentURIEvent",
+ has_initializer: true,
+ has_immutable_config: false,
+ internal_impls: vec!["InternalImpl"],
+ },
+ AllowedComponents::ERC6909Metadata => ComponentInfo {
+ name: "ERC6909MetadataComponent",
+ path: "openzeppelin_token::erc6909::extensions::ERC6909MetadataComponent",
+ storage: "erc6909_metadata",
+ event: "ERC6909MetadataEvent",
+ has_initializer: true,
+ has_immutable_config: false,
+ internal_impls: vec!["InternalImpl"],
+ },
+ AllowedComponents::ERC6909TokenSupply => ComponentInfo {
+ name: "ERC6909TokenSupplyComponent",
+ path: "openzeppelin_token::erc6909::extensions::ERC6909TokenSupplyComponent",
+ storage: "erc6909_token_supply",
+ event: "ERC6909TokenSupplyEvent",
+ has_initializer: true,
+ has_immutable_config: false,
+ internal_impls: vec!["InternalImpl"],
+ },
AllowedComponents::Upgradeable => ComponentInfo {
name: "UpgradeableComponent",
path: "openzeppelin_upgrades::UpgradeableComponent",
diff --git a/packages/macros/src/attribute/with_components/diagnostics.rs b/packages/macros/src/attribute/with_components/diagnostics.rs
index 81ffb3797..fa50f1dca 100644
--- a/packages/macros/src/attribute/with_components/diagnostics.rs
+++ b/packages/macros/src/attribute/with_components/diagnostics.rs
@@ -110,6 +110,17 @@ pub mod warnings {
"
};
+ /// Warning when the ERC6909 component is missing an implementation of the ERC6909HooksTrait
+ pub const ERC6909_HOOKS_IMPL_MISSING: &str = indoc! {
+ "The ERC6909 component requires an implementation of the ERC6909HooksTrait in scope and
+ it looks like it is missing.
+
+ You can use the ERC6909HooksEmptyImpl implementation by importing it:
+
+ `use openzeppelin_token::erc6909::ERC6909HooksEmptyImpl;`
+ "
+ };
+
/// Warning when the Upgradeable component is not used.
pub const UPGRADEABLE_NOT_USED: &str = indoc! {
"It looks like the `self.upgradeable.upgrade(new_class_hash)` function is not used in the contract. If
diff --git a/packages/macros/src/attribute/with_components/parser.rs b/packages/macros/src/attribute/with_components/parser.rs
index bb5206136..381239542 100644
--- a/packages/macros/src/attribute/with_components/parser.rs
+++ b/packages/macros/src/attribute/with_components/parser.rs
@@ -308,6 +308,15 @@ fn add_per_component_warnings(code: &str, component_info: &ComponentInfo) -> Vec
warnings.push(warning);
}
}
+ AllowedComponents::ERC6909 => {
+ // Check that the ERC6909HooksTrait is implemented
+ let hooks_trait_used = code.contains("ERC6909HooksTrait");
+ let hooks_empty_impl_used = code.contains("ERC6909HooksEmptyImpl");
+ if !hooks_trait_used && !hooks_empty_impl_used {
+ let warning = Diagnostic::warn(warnings::ERC6909_HOOKS_IMPL_MISSING);
+ warnings.push(warning);
+ }
+ }
AllowedComponents::Upgradeable => {
// Check that the upgrade function is called
let upgrade_function_called = code.contains("self.upgradeable.upgrade");
diff --git a/packages/test_common/src/erc6909.cairo b/packages/test_common/src/erc6909.cairo
new file mode 100644
index 000000000..1c7a71568
--- /dev/null
+++ b/packages/test_common/src/erc6909.cairo
@@ -0,0 +1,92 @@
+use openzeppelin_testing::{EventSpyExt, EventSpyQueue as EventSpy, ExpectedEvent};
+use starknet::ContractAddress;
+
+#[generate_trait]
+pub impl ERC6909SpyHelpersImpl of ERC6909SpyHelpers {
+ fn assert_event_operator_set(
+ ref self: EventSpy,
+ contract: ContractAddress,
+ owner: ContractAddress,
+ spender: ContractAddress,
+ approved: bool,
+ ) {
+ let expected = ExpectedEvent::new()
+ .key(selector!("OperatorSet"))
+ .key(owner)
+ .key(spender)
+ .data(approved);
+ self.assert_emitted_single(contract, expected);
+ }
+
+ fn assert_only_event_operator_set(
+ ref self: EventSpy,
+ contract: ContractAddress,
+ owner: ContractAddress,
+ spender: ContractAddress,
+ approved: bool,
+ ) {
+ self.assert_event_operator_set(contract, owner, spender, approved);
+ self.assert_no_events_left_from(contract);
+ }
+
+ fn assert_event_approval(
+ ref self: EventSpy,
+ contract: ContractAddress,
+ owner: ContractAddress,
+ spender: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ let expected = ExpectedEvent::new()
+ .key(selector!("Approval"))
+ .key(owner)
+ .key(spender)
+ .key(id)
+ .data(amount);
+ self.assert_emitted_single(contract, expected);
+ }
+
+ fn assert_only_event_approval(
+ ref self: EventSpy,
+ contract: ContractAddress,
+ owner: ContractAddress,
+ spender: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ self.assert_event_approval(contract, owner, spender, id, amount);
+ self.assert_no_events_left_from(contract);
+ }
+
+ fn assert_event_transfer(
+ ref self: EventSpy,
+ contract: ContractAddress,
+ caller: ContractAddress,
+ sender: ContractAddress,
+ receiver: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ let expected = ExpectedEvent::new()
+ .key(selector!("Transfer"))
+ .data(caller)
+ .key(sender)
+ .key(receiver)
+ .key(id)
+ .data(amount);
+ self.assert_emitted_single(contract, expected);
+ }
+
+ fn assert_only_event_transfer(
+ ref self: EventSpy,
+ contract: ContractAddress,
+ caller: ContractAddress,
+ sender: ContractAddress,
+ receiver: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ self.assert_event_transfer(contract, caller, sender, receiver, id, amount);
+ self.assert_no_events_left_from(contract);
+ }
+}
diff --git a/packages/test_common/src/lib.cairo b/packages/test_common/src/lib.cairo
index fc1d892b5..56d8dac92 100644
--- a/packages/test_common/src/lib.cairo
+++ b/packages/test_common/src/lib.cairo
@@ -2,6 +2,7 @@ pub mod account;
pub mod common;
pub mod erc1155;
pub mod erc20;
+pub mod erc6909;
pub mod erc721;
pub mod eth_account;
pub mod math;
diff --git a/packages/test_common/src/mocks.cairo b/packages/test_common/src/mocks.cairo
index cccf9a26b..8df763166 100644
--- a/packages/test_common/src/mocks.cairo
+++ b/packages/test_common/src/mocks.cairo
@@ -6,6 +6,7 @@ pub mod erc1155;
pub mod erc20;
pub mod erc2981;
pub mod erc4626;
+pub mod erc6909;
pub mod erc721;
pub mod governor;
pub mod multisig;
diff --git a/packages/test_common/src/mocks/erc6909.cairo b/packages/test_common/src/mocks/erc6909.cairo
new file mode 100644
index 000000000..224b1f8e2
--- /dev/null
+++ b/packages/test_common/src/mocks/erc6909.cairo
@@ -0,0 +1,186 @@
+#[starknet::contract]
+#[with_components(ERC6909, SRC5)]
+pub mod ERC6909Mock {
+ use openzeppelin_token::erc6909::ERC6909HooksEmptyImpl;
+ use starknet::ContractAddress;
+
+
+ #[abi(embed_v0)]
+ impl ERC6909Impl = ERC6909Component::ERC6909Impl;
+
+ #[abi(embed_v0)]
+ impl SRC5Impl = SRC5Component::SRC5Impl;
+
+ #[storage]
+ pub struct Storage {}
+
+ #[constructor]
+ fn constructor(ref self: ContractState, recipient: ContractAddress, id: u256, amount: u256) {
+ self.erc6909.initializer();
+ self.erc6909._mint(recipient, id, amount);
+ }
+}
+
+
+#[starknet::contract]
+#[with_components(ERC6909, SRC5)]
+pub mod ERC6909MockWithHooks {
+ use starknet::ContractAddress;
+
+
+ #[abi(embed_v0)]
+ impl ERC6909Impl = ERC6909Component::ERC6909Impl;
+
+ #[abi(embed_v0)]
+ impl SRC5Impl = SRC5Component::SRC5Impl;
+
+ #[storage]
+ pub struct Storage {}
+
+ #[event]
+ #[derive(Drop, starknet::Event)]
+ pub enum Event {
+ BeforeUpdate: BeforeUpdate,
+ AfterUpdate: AfterUpdate,
+ }
+
+
+ #[derive(Drop, PartialEq, starknet::Event)]
+ pub struct BeforeUpdate {
+ pub from: ContractAddress,
+ pub recipient: ContractAddress,
+ pub id: u256,
+ pub amount: u256,
+ }
+
+
+ #[derive(Drop, PartialEq, starknet::Event)]
+ pub struct AfterUpdate {
+ pub from: ContractAddress,
+ pub recipient: ContractAddress,
+ pub id: u256,
+ pub amount: u256,
+ }
+
+ #[constructor]
+ fn constructor(ref self: ContractState, recipient: ContractAddress, id: u256, amount: u256) {
+ self.erc6909.initializer();
+ self.erc6909._mint(recipient, id, amount);
+ }
+
+ impl ERC6909HooksImpl of ERC6909Component::ERC6909HooksTrait {
+ fn before_update(
+ ref self: ERC6909Component::ComponentState,
+ from: ContractAddress,
+ recipient: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ let mut contract_state = self.get_contract_mut();
+ contract_state.emit(BeforeUpdate { from, recipient, id, amount });
+ }
+
+ fn after_update(
+ ref self: ERC6909Component::ComponentState,
+ from: ContractAddress,
+ recipient: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ let mut contract_state = self.get_contract_mut();
+ contract_state.emit(AfterUpdate { from, recipient, id, amount });
+ }
+ }
+}
+
+#[starknet::contract]
+#[with_components(ERC6909, ERC6909ContentURI, SRC5)]
+pub mod ERC6909ContentURIMock {
+ use openzeppelin_token::erc6909::ERC6909HooksEmptyImpl;
+ use starknet::ContractAddress;
+
+ #[abi(embed_v0)]
+ impl ERC6909Impl = ERC6909Component::ERC6909Impl;
+ #[abi(embed_v0)]
+ impl ERC6909ContentURIImpl =
+ ERC6909ContentURIComponent::ERC6909ContentURIImpl;
+ #[abi(embed_v0)]
+ impl SRC5Impl = SRC5Component::SRC5Impl;
+
+ #[storage]
+ pub struct Storage {}
+
+ #[constructor]
+ fn constructor(
+ ref self: ContractState,
+ contract_uri: ByteArray,
+ recipient: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ self.erc6909.initializer();
+ self.erc6909_content_uri.initializer(contract_uri);
+ self.erc6909._mint(recipient, id, amount);
+ }
+}
+
+#[starknet::contract]
+#[with_components(ERC6909, ERC6909Metadata, SRC5)]
+pub mod ERC6909MetadataMock {
+ use openzeppelin_token::erc6909::ERC6909HooksEmptyImpl;
+ use starknet::ContractAddress;
+
+ #[abi(embed_v0)]
+ impl ERC6909Impl = ERC6909Component::ERC6909Impl;
+ #[abi(embed_v0)]
+ impl ERC6909MetadataImpl =
+ ERC6909MetadataComponent::ERC6909MetadataImpl;
+ #[abi(embed_v0)]
+ impl SRC5Impl = SRC5Component::SRC5Impl;
+
+ #[storage]
+ pub struct Storage {}
+
+ #[constructor]
+ fn constructor(
+ ref self: ContractState,
+ name: ByteArray,
+ symbol: ByteArray,
+ decimals: u8,
+ recipient: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ self.erc6909.initializer();
+ self.erc6909_metadata.initializer();
+ self.erc6909_metadata._set_token_metadata(id, name, symbol, decimals);
+ self.erc6909._mint(recipient, id, amount);
+ }
+}
+
+#[starknet::contract]
+#[with_components(ERC6909, ERC6909TokenSupply, SRC5)]
+pub mod ERC6909TokenSupplyMock {
+ use openzeppelin_token::erc6909::ERC6909HooksEmptyImpl;
+ use starknet::ContractAddress;
+
+
+ #[abi(embed_v0)]
+ impl ERC6909Impl = ERC6909Component::ERC6909Impl;
+ #[abi(embed_v0)]
+ impl ERC6909TokenSupplyImpl =
+ ERC6909TokenSupplyComponent::ERC6909TokenSupplyImpl;
+ #[abi(embed_v0)]
+ impl SRC5Impl = SRC5Component::SRC5Impl;
+
+ #[storage]
+ pub struct Storage {}
+
+
+ #[constructor]
+ fn constructor(ref self: ContractState, recipient: ContractAddress, id: u256, amount: u256) {
+ self.erc6909.initializer();
+ self.erc6909_token_supply.initializer();
+ self.erc6909._mint(recipient, id, amount);
+ }
+}
diff --git a/packages/token/src/erc6909.cairo b/packages/token/src/erc6909.cairo
new file mode 100644
index 000000000..2f5f01a73
--- /dev/null
+++ b/packages/token/src/erc6909.cairo
@@ -0,0 +1,4 @@
+pub mod erc6909;
+pub mod extensions;
+
+pub use erc6909::{ERC6909Component, ERC6909HooksEmptyImpl};
diff --git a/packages/token/src/erc6909/erc6909.cairo b/packages/token/src/erc6909/erc6909.cairo
new file mode 100644
index 000000000..ea4932c7e
--- /dev/null
+++ b/packages/token/src/erc6909/erc6909.cairo
@@ -0,0 +1,350 @@
+// SPDX-License-Identifier: MIT
+
+/// # ERC6909 Component
+///
+/// The ERC6909 component provides an implementation of the Minimal Multi-Token standard authored by
+/// jtriley.eth See https://eips.ethereum.org/EIPS/eip-6909.
+#[starknet::component]
+pub mod ERC6909Component {
+ use core::num::traits::{Bounded, Zero};
+ use openzeppelin_interfaces::erc6909 as interface;
+ use openzeppelin_introspection::src5::SRC5Component;
+ use openzeppelin_introspection::src5::SRC5Component::{
+ InternalTrait as SRC5InternalTrait, SRC5Impl,
+ };
+ use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess};
+ use starknet::{ContractAddress, get_caller_address};
+
+ #[storage]
+ pub struct Storage {
+ ERC6909_balances: Map<(ContractAddress, u256), u256>,
+ ERC6909_allowances: Map<(ContractAddress, ContractAddress, u256), u256>,
+ ERC6909_operators: Map<(ContractAddress, ContractAddress), bool>,
+ }
+
+ #[event]
+ #[derive(Drop, PartialEq, starknet::Event)]
+ pub enum Event {
+ Transfer: Transfer,
+ Approval: Approval,
+ OperatorSet: OperatorSet,
+ }
+
+ /// Emitted when `id` tokens are moved from address `from` to address `to`.
+ #[derive(Drop, PartialEq, starknet::Event)]
+ pub struct Transfer {
+ pub caller: ContractAddress,
+ #[key]
+ pub sender: ContractAddress,
+ #[key]
+ pub receiver: ContractAddress,
+ #[key]
+ pub id: u256,
+ pub amount: u256,
+ }
+
+ /// Emitted when the allowance of a `spender` for an `owner` is set by a call
+ /// to `approve` over `id`
+ #[derive(Drop, PartialEq, starknet::Event)]
+ pub struct Approval {
+ #[key]
+ pub owner: ContractAddress,
+ #[key]
+ pub spender: ContractAddress,
+ #[key]
+ pub id: u256,
+ pub amount: u256,
+ }
+
+ /// Emitted when `account` enables or disables (`approved`) `spender` to manage
+ /// all of its assets.
+ #[derive(Drop, PartialEq, starknet::Event)]
+ pub struct OperatorSet {
+ #[key]
+ pub owner: ContractAddress,
+ #[key]
+ pub spender: ContractAddress,
+ pub approved: bool,
+ }
+
+ pub mod Errors {
+ pub const INSUFFICIENT_BALANCE: felt252 = 'ERC6909: insufficient balance';
+ pub const INSUFFICIENT_ALLOWANCE: felt252 = 'ERC6909: insufficient allowance';
+ pub const INVALID_APPROVER: felt252 = 'ERC6909: invalid approver';
+ pub const INVALID_RECEIVER: felt252 = 'ERC6909: invalid receiver';
+ pub const INVALID_SENDER: felt252 = 'ERC6909: invalid sender';
+ pub const INVALID_SPENDER: felt252 = 'ERC6909: invalid spender';
+ }
+
+
+ //
+ // Hooks
+ //
+
+ pub trait ERC6909HooksTrait {
+ fn before_update(
+ ref self: ComponentState,
+ from: ContractAddress,
+ recipient: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {}
+
+ fn after_update(
+ ref self: ComponentState,
+ from: ContractAddress,
+ recipient: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {}
+ }
+
+
+ #[embeddable_as(ERC6909Impl)]
+ impl ERC6909<
+ TContractState,
+ +HasComponent,
+ +SRC5Component::HasComponent,
+ +ERC6909HooksTrait,
+ +Drop,
+ > of interface::IERC6909> {
+ /// Returns the amount of `id` tokens owned by `account`.
+ fn balance_of(
+ self: @ComponentState, owner: ContractAddress, id: u256,
+ ) -> u256 {
+ self.ERC6909_balances.read((owner, id))
+ }
+
+ /// Returns the remaining number of `id` tokens that `spender` is
+ /// allowed to spend on behalf of `owner` through `transfer_from`.
+ /// This is zero by default.
+ fn allowance(
+ self: @ComponentState,
+ owner: ContractAddress,
+ spender: ContractAddress,
+ id: u256,
+ ) -> u256 {
+ self.ERC6909_allowances.read((owner, spender, id))
+ }
+
+ /// Returns if a spender is approved by an owner as an operator
+ fn is_operator(
+ self: @ComponentState, owner: ContractAddress, spender: ContractAddress,
+ ) -> bool {
+ self.ERC6909_operators.read((owner, spender))
+ }
+
+ /// Transfers an amount of an id to a receiver.
+ fn transfer(
+ ref self: ComponentState,
+ receiver: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) -> bool {
+ let caller = get_caller_address();
+ self._transfer(caller, receiver, id, amount);
+ true
+ }
+
+ /// Transfers an amount of an id from a sender to a receiver.
+ fn transfer_from(
+ ref self: ComponentState,
+ sender: ContractAddress,
+ receiver: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) -> bool {
+ let caller = get_caller_address();
+ self._spend_allowance(sender, caller, id, amount);
+ self._transfer(sender, receiver, id, amount);
+ true
+ }
+
+ /// Approves an amount of an id to a spender.
+ fn approve(
+ ref self: ComponentState,
+ spender: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) -> bool {
+ let caller = get_caller_address();
+ self._approve(caller, spender, id, amount);
+ true
+ }
+
+ /// Sets or unsets a spender as an operator for the caller.
+ fn set_operator(
+ ref self: ComponentState, spender: ContractAddress, approved: bool,
+ ) -> bool {
+ let caller = get_caller_address();
+ self._set_operator(caller, spender, approved);
+ true
+ }
+ }
+
+
+ #[generate_trait]
+ pub impl InternalImpl<
+ TContractState,
+ +HasComponent,
+ impl SRC5: SRC5Component::HasComponent,
+ impl Hooks: ERC6909HooksTrait,
+ +Drop,
+ > of InternalTrait {
+ /// Initializes the contract by regisrtering the supported interfaces
+ /// This should only be used inside the contract's constructor.
+ fn initializer(ref self: ComponentState) {
+ let mut src5_component = get_dep_component_mut!(ref self, SRC5);
+ src5_component.register_interface(interface::IERC6909_ID);
+ }
+
+ /// Creates a `value` amount of tokens and assigns them to `account`.
+ ///
+ /// Requirements:
+ ///
+ /// - `receiver` is not the zero address.
+ ///
+ /// Emits a `Transfer` event with `from` set to the zero address.
+ fn _mint(
+ ref self: ComponentState,
+ receiver: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ assert(!receiver.is_zero(), Errors::INVALID_RECEIVER);
+ self._update(Zero::zero(), receiver, id, amount);
+ }
+
+ /// Destroys `amount` of tokens from `account`.
+ ///
+ /// Requirements:
+ ///
+ /// - `account` is not the zero address.
+ /// - `account` must have at least a balance of `amount`.
+ ///
+ /// Emits a `Transfer` event with `to` set to the zero address.
+ fn _burn(
+ ref self: ComponentState,
+ account: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ assert(!account.is_zero(), Errors::INVALID_SENDER);
+ self._update(account, Zero::zero(), id, amount);
+ }
+
+ /// Transfers an `amount` of tokens from `sender` to `receiver`, or alternatively mints (or
+ /// burns) if `sender` (or `receiver`) is the zero address.
+ ///
+ /// This function can be extended using the `before_update` and `after_update` hooks.
+ /// The implementation does not keep track of individual token supplies and this logic is
+ /// left to the extensions instead.
+ ///
+ /// Emits a `Transfer` event.
+ fn _update(
+ ref self: ComponentState,
+ sender: ContractAddress,
+ receiver: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ Hooks::before_update(ref self, sender, receiver, id, amount);
+
+ if (sender.is_non_zero()) {
+ let sender_balance = self.ERC6909_balances.read((sender, id));
+ assert(sender_balance >= amount, Errors::INSUFFICIENT_BALANCE);
+ self.ERC6909_balances.write((sender, id), sender_balance - amount);
+ }
+
+ if (receiver.is_non_zero()) {
+ let receiver_balance = self.ERC6909_balances.read((receiver, id));
+ self.ERC6909_balances.write((receiver, id), receiver_balance + amount);
+ }
+
+ self.emit(Transfer { caller: get_caller_address(), sender, receiver, id, amount });
+
+ Hooks::after_update(ref self, sender, receiver, id, amount);
+ }
+
+ /// Sets or unsets a spender as an operator for the caller.
+ fn _set_operator(
+ ref self: ComponentState,
+ owner: ContractAddress,
+ spender: ContractAddress,
+ approved: bool,
+ ) {
+ self.ERC6909_operators.write((owner, spender), approved);
+ self.emit(OperatorSet { owner, spender, approved });
+ }
+
+ /// Updates `sender`'s allowance for `spender` and `id` based on spent `amount`.
+ /// Does not update the allowance value in case of infinite allowance or if spender is
+ /// operator.
+ fn _spend_allowance(
+ ref self: ComponentState,
+ owner: ContractAddress,
+ spender: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ // In accordance with the transferFrom method, spenders with operator permission are not
+ // subject to allowance restrictions (https://eips.ethereum.org/EIPS/eip-6909).
+ if owner != spender && !self.ERC6909_operators.read((owner, spender)) {
+ let sender_allowance = self.ERC6909_allowances.read((owner, spender, id));
+
+ if sender_allowance != Bounded::MAX {
+ assert(sender_allowance >= amount, Errors::INSUFFICIENT_ALLOWANCE);
+ self._approve(owner, spender, id, sender_allowance - amount)
+ }
+ }
+ }
+
+ /// Internal method that sets `amount` as the allowance of `spender` over the
+ /// `owner`s tokens.
+ ///
+ /// Requirements:
+ ///
+ /// - `owner` is not the zero address.
+ /// - `spender` is not the zero address.
+ ///
+ /// Emits an `Approval` event.
+ fn _approve(
+ ref self: ComponentState,
+ owner: ContractAddress,
+ spender: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ assert(owner.is_non_zero(), Errors::INVALID_APPROVER);
+ assert(spender.is_non_zero(), Errors::INVALID_SPENDER);
+ self.ERC6909_allowances.write((owner, spender, id), amount);
+ self.emit(Approval { owner, spender, id, amount });
+ }
+
+ /// Internal method that moves an `amount` of tokens from `sender` to `receiver`.
+ ///
+ /// Requirements:
+ ///
+ /// - `sender` is not the zero address.
+ /// - `sender` must have at least a balance of `amount`.
+ /// - `receiver` is not the zero address.
+ ///
+ /// Emits a `Transfer` event.
+ fn _transfer(
+ ref self: ComponentState,
+ sender: ContractAddress,
+ receiver: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ assert(!sender.is_zero(), Errors::INVALID_SENDER);
+ assert(!receiver.is_zero(), Errors::INVALID_RECEIVER);
+ self._update(sender, receiver, id, amount);
+ }
+ }
+}
+
+/// An empty implementation of the ERC6909 hooks to be used in basic ERC6909 preset contracts.
+pub impl ERC6909HooksEmptyImpl<
+ TContractState,
+> of ERC6909Component::ERC6909HooksTrait {}
diff --git a/packages/token/src/erc6909/extensions.cairo b/packages/token/src/erc6909/extensions.cairo
new file mode 100644
index 000000000..edbe7bead
--- /dev/null
+++ b/packages/token/src/erc6909/extensions.cairo
@@ -0,0 +1,7 @@
+pub mod erc6909_content_uri;
+pub mod erc6909_metadata;
+pub mod erc6909_token_supply;
+
+pub use erc6909_content_uri::ERC6909ContentURIComponent;
+pub use erc6909_metadata::ERC6909MetadataComponent;
+pub use erc6909_token_supply::ERC6909TokenSupplyComponent;
diff --git a/packages/token/src/erc6909/extensions/erc6909_content_uri.cairo b/packages/token/src/erc6909/extensions/erc6909_content_uri.cairo
new file mode 100644
index 000000000..f1ce1a172
--- /dev/null
+++ b/packages/token/src/erc6909/extensions/erc6909_content_uri.cairo
@@ -0,0 +1,64 @@
+// SPDX-License-Identifier: MIT
+// OpenZeppelin Contracts for Cairo v0.14.0 (token/erc6909/extensions/erc6909_votes.cairo)
+
+/// # ERC6909ContentURI Component
+///
+/// The ERC6909ContentURI component allows to set the contract and token ID URIs.
+/// The internal function `initializer` should be used ideally in the constructor.
+#[starknet::component]
+pub mod ERC6909ContentURIComponent {
+ use openzeppelin_interfaces::erc6909 as interface;
+ use openzeppelin_introspection::src5::SRC5Component;
+ use openzeppelin_introspection::src5::SRC5Component::InternalTrait as SRC5InternalTrait;
+ use openzeppelin_token::erc6909::ERC6909Component;
+ use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
+
+ #[storage]
+ pub struct Storage {
+ ERC6909ContentURI_contract_uri: ByteArray,
+ }
+
+ #[embeddable_as(ERC6909ContentURIImpl)]
+ impl ERC6909ContentURI<
+ TContractState,
+ +HasComponent,
+ +ERC6909Component::HasComponent,
+ +ERC6909Component::ERC6909HooksTrait,
+ +Drop,
+ > of interface::IERC6909ContentUri> {
+ /// Returns the contract level URI.
+ fn contract_uri(self: @ComponentState) -> ByteArray {
+ self.ERC6909ContentURI_contract_uri.read()
+ }
+
+ /// Returns the token level URI.
+ fn token_uri(self: @ComponentState, id: u256) -> ByteArray {
+ let contract_uri = self.contract_uri();
+ if contract_uri.len() == 0 {
+ ""
+ } else {
+ format!("{}{}", contract_uri, id)
+ }
+ }
+ }
+
+ #[generate_trait]
+ pub impl InternalImpl<
+ TContractState,
+ +HasComponent,
+ impl ERC6909: ERC6909Component::HasComponent,
+ +ERC6909Component::ERC6909HooksTrait,
+ impl SRC5: SRC5Component::HasComponent,
+ +Drop,
+ > of InternalTrait {
+ /// Initializes the contract by setting the contract uri and declaring support
+ /// for the `IERC6909ContentUri` interface id.
+ fn initializer(ref self: ComponentState, contract_uri: ByteArray) {
+ self.ERC6909ContentURI_contract_uri.write(contract_uri);
+
+ let mut src5_component = get_dep_component_mut!(ref self, SRC5);
+ src5_component.register_interface(interface::IERC6909_CONTENT_URI_ID);
+ }
+ }
+}
+
diff --git a/packages/token/src/erc6909/extensions/erc6909_metadata.cairo b/packages/token/src/erc6909/extensions/erc6909_metadata.cairo
new file mode 100644
index 000000000..572139571
--- /dev/null
+++ b/packages/token/src/erc6909/extensions/erc6909_metadata.cairo
@@ -0,0 +1,118 @@
+// SPDX-License-Identifier: MIT
+
+/// # ERC6909Metadata Component
+///
+/// The ERC6909Metadata component allows to set metadata to the individual token IDs.
+/// The internal function `_update_token_metadata` should be used inside the ERC6909 Hooks.
+#[starknet::component]
+pub mod ERC6909MetadataComponent {
+ use core::num::traits::Zero;
+ use openzeppelin_interfaces::erc6909 as interface;
+ use openzeppelin_introspection::src5::SRC5Component;
+ use openzeppelin_introspection::src5::SRC5Component::InternalTrait as SRC5InternalTrait;
+ use openzeppelin_token::erc6909::ERC6909Component;
+ use starknet::ContractAddress;
+ use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess};
+
+ #[storage]
+ pub struct Storage {
+ ERC6909Metadata_name: Map,
+ ERC6909Metadata_symbol: Map,
+ ERC6909Metadata_decimals: Map,
+ }
+
+ #[embeddable_as(ERC6909MetadataImpl)]
+ impl ERC6909Metadata<
+ TContractState,
+ +HasComponent,
+ +ERC6909Component::HasComponent,
+ +ERC6909Component::ERC6909HooksTrait,
+ +Drop,
+ > of interface::IERC6909Metadata> {
+ /// Returns the name of a token ID
+ fn name(self: @ComponentState, id: u256) -> ByteArray {
+ self.ERC6909Metadata_name.read(id)
+ }
+
+ /// Returns the symbol of a token ID
+ fn symbol(self: @ComponentState, id: u256) -> ByteArray {
+ self.ERC6909Metadata_symbol.read(id)
+ }
+
+ /// Returns the decimals of a token ID
+ fn decimals(self: @ComponentState, id: u256) -> u8 {
+ self.ERC6909Metadata_decimals.read(id)
+ }
+ }
+
+ #[generate_trait]
+ pub impl InternalImpl<
+ TContractState,
+ +HasComponent,
+ impl ERC6909: ERC6909Component::HasComponent,
+ +ERC6909Component::ERC6909HooksTrait,
+ impl SRC5: SRC5Component::HasComponent,
+ +Drop,
+ > of InternalTrait {
+ /// Initializes the contract by declaring support for the `IERC6909Metadata`
+ /// interface id.
+ fn initializer(ref self: ComponentState) {
+ let mut src5_component = get_dep_component_mut!(ref self, SRC5);
+ src5_component.register_interface(interface::IERC6909_METADATA_ID);
+ }
+
+ /// Updates the metadata of a token ID.
+ fn _update_token_metadata(
+ ref self: ComponentState,
+ sender: ContractAddress,
+ id: u256,
+ name: ByteArray,
+ symbol: ByteArray,
+ decimals: u8,
+ ) {
+ // In case of new ID mints update the token metadata
+ if (sender.is_zero()) {
+ let token_metadata_exists = self._token_metadata_exists(id);
+ if (!token_metadata_exists) {
+ self._set_token_metadata(id, name, symbol, decimals)
+ }
+ }
+ }
+
+ /// Checks if a token has metadata at the time of minting.
+ fn _token_metadata_exists(self: @ComponentState, id: u256) -> bool {
+ return self.ERC6909Metadata_name.read(id).len() > 0;
+ }
+
+ /// Updates the token metadata for `id`.
+ fn _set_token_metadata(
+ ref self: ComponentState,
+ id: u256,
+ name: ByteArray,
+ symbol: ByteArray,
+ decimals: u8,
+ ) {
+ self._set_token_name(id, name);
+ self._set_token_symbol(id, symbol);
+ self._set_token_decimals(id, decimals);
+ }
+
+ /// Sets the token name.
+ fn _set_token_name(ref self: ComponentState, id: u256, name: ByteArray) {
+ self.ERC6909Metadata_name.write(id, name);
+ }
+
+ /// Sets the token symbol.
+ fn _set_token_symbol(
+ ref self: ComponentState, id: u256, symbol: ByteArray,
+ ) {
+ self.ERC6909Metadata_symbol.write(id, symbol);
+ }
+
+ /// Sets the token decimals.
+ fn _set_token_decimals(ref self: ComponentState, id: u256, decimals: u8) {
+ self.ERC6909Metadata_decimals.write(id, decimals);
+ }
+ }
+}
+
diff --git a/packages/token/src/erc6909/extensions/erc6909_token_supply.cairo b/packages/token/src/erc6909/extensions/erc6909_token_supply.cairo
new file mode 100644
index 000000000..343b1a07c
--- /dev/null
+++ b/packages/token/src/erc6909/extensions/erc6909_token_supply.cairo
@@ -0,0 +1,80 @@
+// SPDX-License-Identifier: MIT
+
+/// # ERC6909TokenSupply Component
+///
+/// The ERC6909TokenSupply component allows to keep track of individual token ID supplies.
+/// The internal function `_update_token_supply` should be used inside the ERC6909 Hooks.
+#[starknet::component]
+pub mod ERC6909TokenSupplyComponent {
+ use core::num::traits::Zero;
+ use openzeppelin_interfaces::erc6909 as interface;
+ use openzeppelin_introspection::src5::SRC5Component;
+ use openzeppelin_introspection::src5::SRC5Component::InternalTrait as SRC5InternalTrait;
+ use openzeppelin_token::erc6909::ERC6909Component;
+ use starknet::ContractAddress;
+ use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess};
+
+ #[storage]
+ pub struct Storage {
+ ERC6909TokenSupply_total_supply: Map,
+ }
+
+ #[embeddable_as(ERC6909TokenSupplyImpl)]
+ impl ERC6909TokenSupply<
+ TContractState,
+ +HasComponent,
+ +ERC6909Component::HasComponent,
+ +ERC6909Component::ERC6909HooksTrait,
+ +Drop,
+ > of interface::IERC6909TokenSupply> {
+ /// Returns the total supply of a token.
+ fn total_supply(self: @ComponentState, id: u256) -> u256 {
+ self.ERC6909TokenSupply_total_supply.read(id)
+ }
+ }
+
+ //
+ // Internal
+ //
+
+ #[generate_trait]
+ pub impl InternalImpl<
+ TContractState,
+ +HasComponent,
+ impl ERC6909: ERC6909Component::HasComponent,
+ +ERC6909Component::ERC6909HooksTrait,
+ impl SRC5: SRC5Component::HasComponent,
+ +Drop,
+ > of InternalTrait {
+ /// Initializes the contract by declaring support for the `IERC6909TokenSupply`
+ /// interface id.
+ fn initializer(ref self: ComponentState) {
+ let mut src5_component = get_dep_component_mut!(ref self, SRC5);
+ src5_component.register_interface(interface::IERC6909_TOKEN_SUPPLY_ID);
+ }
+
+ /// Updates the total supply of a token ID.
+ /// Ideally this function should be called in a `before_update` or `after_update`
+ /// hook during mints and burns.
+ fn _update_token_supply(
+ ref self: ComponentState,
+ sender: ContractAddress,
+ receiver: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ // In case of mints we increase the total supply of this token ID
+ if (sender.is_zero()) {
+ let total_supply = self.ERC6909TokenSupply_total_supply.read(id);
+ self.ERC6909TokenSupply_total_supply.write(id, total_supply + amount);
+ }
+
+ // In case of burns we decrease the total supply of this token ID
+ if (receiver.is_zero()) {
+ let total_supply = self.ERC6909TokenSupply_total_supply.read(id);
+ self.ERC6909TokenSupply_total_supply.write(id, total_supply - amount);
+ }
+ }
+ }
+}
+
diff --git a/packages/token/src/lib.cairo b/packages/token/src/lib.cairo
index 43b3de05d..73241e725 100644
--- a/packages/token/src/lib.cairo
+++ b/packages/token/src/lib.cairo
@@ -1,6 +1,7 @@
pub mod common;
pub mod erc1155;
pub mod erc20;
+pub mod erc6909;
pub mod erc721;
#[cfg(test)]
diff --git a/packages/token/src/tests.cairo b/packages/token/src/tests.cairo
index a9654141f..523aef8f8 100644
--- a/packages/token/src/tests.cairo
+++ b/packages/token/src/tests.cairo
@@ -2,4 +2,5 @@ pub mod erc1155;
pub mod erc20;
pub mod erc2981;
pub mod erc4626;
+pub mod erc6909;
pub mod erc721;
diff --git a/packages/token/src/tests/erc6909.cairo b/packages/token/src/tests/erc6909.cairo
new file mode 100644
index 000000000..ab1b5448a
--- /dev/null
+++ b/packages/token/src/tests/erc6909.cairo
@@ -0,0 +1,2 @@
+mod test_erc6909;
+
diff --git a/packages/token/src/tests/erc6909/test_erc6909.cairo b/packages/token/src/tests/erc6909/test_erc6909.cairo
new file mode 100644
index 000000000..a4a068258
--- /dev/null
+++ b/packages/token/src/tests/erc6909/test_erc6909.cairo
@@ -0,0 +1,366 @@
+use core::num::traits::Bounded;
+use openzeppelin_interfaces::erc6909 as interface;
+use openzeppelin_introspection::src5::SRC5Component::SRC5Impl;
+use openzeppelin_test_common::erc6909::ERC6909SpyHelpers;
+use openzeppelin_test_common::mocks::erc6909::{ERC6909Mock, ERC6909MockWithHooks};
+use openzeppelin_testing::constants::{OWNER, RECIPIENT, SPENDER, SUPPLY, TOKEN_ID, VALUE, ZERO};
+use openzeppelin_testing::{EventSpyExt, EventSpyQueue as EventSpy, ExpectedEvent, spy_events};
+use snforge_std::{start_cheat_caller_address, test_address};
+use starknet::ContractAddress;
+use crate::erc6909::ERC6909Component;
+use crate::erc6909::ERC6909Component::{ERC6909Impl, InternalImpl};
+
+type ComponentState = ERC6909Component::ComponentState;
+type ComponentStateWithHooks =
+ ERC6909Component::ComponentState;
+
+
+fn CONTRACT_STATE() -> ERC6909Mock::ContractState {
+ ERC6909Mock::contract_state_for_testing()
+}
+
+fn COMPONENT_STATE() -> ComponentState {
+ ERC6909Component::component_state_for_testing()
+}
+
+fn COMPONENT_STATE_WITH_HOOKS() -> ComponentStateWithHooks {
+ ERC6909Component::component_state_for_testing()
+}
+
+fn setup() -> ComponentState {
+ let mut state = COMPONENT_STATE();
+ state.initializer();
+ state._mint(OWNER, TOKEN_ID, SUPPLY);
+ state
+}
+
+fn setup_with_hooks() -> ComponentStateWithHooks {
+ let mut state = COMPONENT_STATE_WITH_HOOKS();
+ state.initializer();
+ state._mint(OWNER, TOKEN_ID, SUPPLY);
+ state
+}
+
+
+#[test]
+fn test_initializer() {
+ let mut state = COMPONENT_STATE();
+ let mock_state = CONTRACT_STATE();
+
+ state.initializer();
+
+ let supports_ierc6909 = mock_state.supports_interface(interface::IERC6909_ID);
+ assert!(supports_ierc6909);
+
+ let supports_isrc5 = mock_state
+ .supports_interface(openzeppelin_interfaces::introspection::ISRC5_ID);
+ assert!(supports_isrc5);
+}
+
+
+#[test]
+fn test_balance_of() {
+ let state = setup();
+ assert_eq!(state.balance_of(OWNER, TOKEN_ID), SUPPLY);
+}
+
+#[test]
+fn test_allowance() {
+ let mut state = setup();
+ assert_eq!(state.allowance(OWNER, SPENDER, TOKEN_ID), 0);
+ state._approve(OWNER, SPENDER, TOKEN_ID, VALUE);
+ assert_eq!(state.allowance(OWNER, SPENDER, TOKEN_ID), VALUE);
+}
+
+#[test]
+fn test_is_operator() {
+ let mut state = COMPONENT_STATE();
+
+ let not_operator = !state.is_operator(OWNER, SPENDER);
+ assert!(not_operator);
+
+ state._set_operator(OWNER, SPENDER, true);
+ let is_operator = state.is_operator(OWNER, SPENDER);
+ assert!(is_operator);
+
+ state._set_operator(OWNER, SPENDER, false);
+ let not_operator = !state.is_operator(OWNER, SPENDER);
+ assert!(not_operator);
+}
+
+
+#[test]
+fn test_transfer_success() {
+ let mut state = setup();
+ let contract_address = test_address();
+ let caller = OWNER;
+ let receiver = RECIPIENT;
+ let id = TOKEN_ID;
+ let amount = VALUE;
+
+ start_cheat_caller_address(contract_address, caller);
+
+ let mut spy = spy_events();
+ assert_state_before_transfer(caller, receiver, id, amount, SUPPLY);
+
+ let ok = state.transfer(receiver, id, amount);
+ assert!(ok);
+
+ spy.assert_only_event_transfer(contract_address, caller, caller, receiver, id, amount);
+ assert_state_after_transfer(caller, receiver, id, amount, SUPPLY);
+}
+
+#[test]
+#[should_panic(expected: 'ERC6909: invalid receiver')]
+fn test_transfer_invalid_receiver_zero() {
+ let mut state = setup();
+ start_cheat_caller_address(test_address(), OWNER);
+ state.transfer(ZERO, TOKEN_ID, VALUE);
+}
+
+#[test]
+#[should_panic(expected: 'ERC6909: insufficient balance')]
+fn test_transfer_insufficient_balance() {
+ let mut state = setup();
+ start_cheat_caller_address(test_address(), SPENDER);
+ state.transfer(RECIPIENT, TOKEN_ID, VALUE);
+}
+
+
+#[test]
+fn test_transfer_from_by_sender_itself() {
+ let mut state = setup();
+ let contract_address = test_address();
+ let sender = OWNER;
+ let receiver = RECIPIENT;
+
+ start_cheat_caller_address(contract_address, sender);
+ let mut spy = spy_events();
+
+ assert_state_before_transfer(sender, receiver, TOKEN_ID, VALUE, SUPPLY);
+ let ok = state.transfer_from(sender, receiver, TOKEN_ID, VALUE);
+ assert!(ok);
+ spy.assert_only_event_transfer(contract_address, sender, sender, receiver, TOKEN_ID, VALUE);
+ assert_state_after_transfer(sender, receiver, TOKEN_ID, VALUE, SUPPLY);
+}
+
+#[test]
+fn test_transfer_from_with_allowance() {
+ let mut state = setup();
+ let contract_address = test_address();
+
+ state._approve(OWNER, SPENDER, TOKEN_ID, VALUE);
+ assert_eq!(state.allowance(OWNER, SPENDER, TOKEN_ID), VALUE);
+
+ start_cheat_caller_address(contract_address, SPENDER);
+ let mut spy = spy_events();
+
+ let ok = state.transfer_from(OWNER, RECIPIENT, TOKEN_ID, VALUE);
+ assert!(ok);
+
+ spy.assert_event_approval(contract_address, OWNER, SPENDER, TOKEN_ID, 0);
+ spy.assert_only_event_transfer(contract_address, SPENDER, OWNER, RECIPIENT, TOKEN_ID, VALUE);
+
+ assert_eq!(state.allowance(OWNER, SPENDER, TOKEN_ID), 0);
+ assert_eq!(state.balance_of(OWNER, TOKEN_ID), SUPPLY - VALUE);
+ assert_eq!(state.balance_of(RECIPIENT, TOKEN_ID), VALUE);
+}
+
+#[test]
+fn test_transfer_from_with_infinite_allowance_does_not_decrease() {
+ let mut state = setup();
+ let contract_address = test_address();
+
+ state._approve(OWNER, SPENDER, TOKEN_ID, Bounded::MAX);
+ assert_eq!(state.allowance(OWNER, SPENDER, TOKEN_ID), Bounded::MAX);
+
+ start_cheat_caller_address(contract_address, SPENDER);
+ state.transfer_from(OWNER, RECIPIENT, TOKEN_ID, VALUE);
+
+ assert_eq!(state.allowance(OWNER, SPENDER, TOKEN_ID), Bounded::MAX);
+ assert_eq!(state.balance_of(OWNER, TOKEN_ID), SUPPLY - VALUE);
+ assert_eq!(state.balance_of(RECIPIENT, TOKEN_ID), VALUE);
+}
+
+#[test]
+fn test_transfer_from_operator_bypass_allowance() {
+ let mut state = setup();
+ let contract_address = test_address();
+
+ state._set_operator(OWNER, SPENDER, true);
+
+ start_cheat_caller_address(contract_address, SPENDER);
+ let mut spy = spy_events();
+
+ let ok = state.transfer_from(OWNER, RECIPIENT, TOKEN_ID, VALUE);
+ assert!(ok);
+
+ spy.assert_only_event_transfer(contract_address, SPENDER, OWNER, RECIPIENT, TOKEN_ID, VALUE);
+ assert_eq!(state.allowance(OWNER, SPENDER, TOKEN_ID), 0);
+}
+
+#[test]
+#[should_panic(expected: 'ERC6909: insufficient allowance')]
+fn test_transfer_from_insufficient_allowance() {
+ let mut state = setup();
+ let lesser = VALUE - 1;
+ state._approve(OWNER, SPENDER, TOKEN_ID, lesser);
+
+ start_cheat_caller_address(test_address(), SPENDER);
+ state.transfer_from(OWNER, RECIPIENT, TOKEN_ID, VALUE);
+}
+
+
+#[test]
+fn test_approve_external() {
+ let mut state = setup();
+ let contract_address = test_address();
+
+ start_cheat_caller_address(contract_address, OWNER);
+ let mut spy = spy_events();
+
+ let ok = state.approve(SPENDER, TOKEN_ID, VALUE);
+ assert!(ok);
+
+ spy.assert_only_event_approval(contract_address, OWNER, SPENDER, TOKEN_ID, VALUE);
+ assert_eq!(state.allowance(OWNER, SPENDER, TOKEN_ID), VALUE);
+}
+
+#[test]
+#[should_panic(expected: 'ERC6909: invalid approver')]
+fn test__approve_invalid_owner_zero() {
+ let mut state = setup();
+ state._approve(ZERO, SPENDER, TOKEN_ID, VALUE);
+}
+
+#[test]
+#[should_panic(expected: 'ERC6909: invalid spender')]
+fn test__approve_invalid_spender_zero() {
+ let mut state = setup();
+ state._approve(OWNER, ZERO, TOKEN_ID, VALUE);
+}
+
+
+#[test]
+fn test_set_operator_external() {
+ let mut state = setup();
+ let contract_address = test_address();
+
+ start_cheat_caller_address(contract_address, OWNER);
+ let mut spy = spy_events();
+
+ let ok = state.set_operator(SPENDER, true);
+ assert!(ok);
+ spy.assert_only_event_operator_set(contract_address, OWNER, SPENDER, true);
+ assert!(state.is_operator(OWNER, SPENDER));
+
+ let ok2 = state.set_operator(SPENDER, false);
+ assert!(ok2);
+ spy.assert_only_event_operator_set(contract_address, OWNER, SPENDER, false);
+ assert!(!state.is_operator(OWNER, SPENDER));
+}
+
+
+#[test]
+fn test__burn_reduces_balance_and_emits() {
+ let mut state = setup();
+ let contract_address = test_address();
+
+ start_cheat_caller_address(contract_address, OWNER);
+ let mut spy = spy_events();
+
+ assert_eq!(state.balance_of(OWNER, TOKEN_ID), SUPPLY);
+ state._burn(OWNER, TOKEN_ID, VALUE);
+
+ spy.assert_only_event_transfer(contract_address, OWNER, OWNER, ZERO, TOKEN_ID, VALUE);
+
+ assert_eq!(state.balance_of(OWNER, TOKEN_ID), SUPPLY - VALUE);
+}
+
+#[test]
+#[should_panic(expected: 'ERC6909: invalid sender')]
+fn test__burn_invalid_sender_zero() {
+ let mut state = setup();
+ state._burn(ZERO, TOKEN_ID, VALUE);
+}
+
+#[test]
+#[should_panic(expected: 'ERC6909: insufficient balance')]
+fn test__burn_insufficient_balance() {
+ let mut state = setup();
+ state._burn(OWNER, TOKEN_ID, SUPPLY + 1);
+}
+
+
+#[test]
+fn test_update_calls_before_and_after_update_hooks_on_transfer() {
+ let mut state = setup_with_hooks();
+ let contract_address = test_address();
+
+ start_cheat_caller_address(contract_address, OWNER);
+ let mut spy = spy_events();
+
+ let amount = VALUE;
+ let id = TOKEN_ID;
+
+ state.transfer(RECIPIENT, id, amount);
+
+ spy.assert_event_before_update(contract_address, OWNER, RECIPIENT, id, amount);
+ spy.assert_event_after_update(contract_address, OWNER, RECIPIENT, id, amount);
+}
+
+
+fn assert_state_before_transfer(
+ sender: ContractAddress, receiver: ContractAddress, id: u256, amount: u256, total: u256,
+) {
+ let state = COMPONENT_STATE();
+ assert_eq!(state.balance_of(sender, id), total);
+ assert_eq!(state.balance_of(receiver, id), 0);
+ assert!(amount <= total);
+}
+
+fn assert_state_after_transfer(
+ sender: ContractAddress, receiver: ContractAddress, id: u256, amount: u256, total_before: u256,
+) {
+ let state = COMPONENT_STATE();
+ assert_eq!(state.balance_of(sender, id), total_before - amount);
+ assert_eq!(state.balance_of(receiver, id), amount);
+}
+
+#[generate_trait]
+impl ERC6909HooksSpyHelpersImpl of ERC6909HooksSpyHelpers {
+ fn assert_event_before_update(
+ ref self: EventSpy,
+ contract: ContractAddress,
+ from: ContractAddress,
+ recipient: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ let expected = ExpectedEvent::new()
+ .key(selector!("BeforeUpdate"))
+ .data(from)
+ .data(recipient)
+ .data(id)
+ .data(amount);
+ self.assert_emitted_single(contract, expected);
+ }
+
+ fn assert_event_after_update(
+ ref self: EventSpy,
+ contract: ContractAddress,
+ from: ContractAddress,
+ recipient: ContractAddress,
+ id: u256,
+ amount: u256,
+ ) {
+ let expected = ExpectedEvent::new()
+ .key(selector!("AfterUpdate"))
+ .data(from)
+ .data(recipient)
+ .data(id)
+ .data(amount);
+ self.assert_emitted_single(contract, expected);
+ }
+}
+
diff --git a/packages/token/src/tests/erc6909/test_erc6909_content_uri.cairo b/packages/token/src/tests/erc6909/test_erc6909_content_uri.cairo
new file mode 100644
index 000000000..157c88e0f
--- /dev/null
+++ b/packages/token/src/tests/erc6909/test_erc6909_content_uri.cairo
@@ -0,0 +1,80 @@
+use openzeppelin_interfaces::erc6909::IERC6909_CONTENT_URI_ID;
+use openzeppelin_interfaces::introspection::ISRC5_ID;
+use openzeppelin_introspection::src5::SRC5Component::SRC5Impl;
+use openzeppelin_test_common::mocks::erc6909::ERC6909ContentURIMock;
+use crate::erc6909::extensions::erc6909_content_uri::ERC6909ContentURIComponent;
+use crate::erc6909::extensions::erc6909_content_uri::ERC6909ContentURIComponent::{
+ ERC6909ContentURIImpl, InternalImpl,
+};
+
+
+fn CONTRACT_URI() -> ByteArray {
+ "ipfs://erc6909/"
+}
+
+const SAMPLE_ID: u256 = 1234;
+
+
+type ComponentState =
+ ERC6909ContentURIComponent::ComponentState;
+
+fn CONTRACT_STATE() -> ERC6909ContentURIMock::ContractState {
+ ERC6909ContentURIMock::contract_state_for_testing()
+}
+
+fn COMPONENT_STATE() -> ComponentState {
+ ERC6909ContentURIComponent::component_state_for_testing()
+}
+
+
+#[test]
+fn test_initializer_registers_interface_and_sets_uri() {
+ let mut state = COMPONENT_STATE();
+ let mut mock_state = CONTRACT_STATE();
+
+ let uri = CONTRACT_URI();
+ state.initializer(uri);
+
+ let supports_content_uri = mock_state.supports_interface(IERC6909_CONTENT_URI_ID);
+ assert!(supports_content_uri);
+
+ let supports_isrc5 = mock_state.supports_interface(ISRC5_ID);
+ assert!(supports_isrc5);
+
+ assert_eq!(state.contract_uri(), uri);
+}
+
+
+#[test]
+fn test_contract_uri_default_is_empty() {
+ let state = COMPONENT_STATE();
+ let empty: ByteArray = "";
+ assert_eq!(state.contract_uri(), empty);
+}
+
+#[test]
+fn test_contract_uri_after_initializer_returns_set_value() {
+ let mut state = COMPONENT_STATE();
+ let uri = CONTRACT_URI();
+ state.initializer(uri);
+
+ assert_eq!(state.contract_uri(), uri);
+}
+
+
+#[test]
+fn test_token_uri_concatenates_contract_uri_and_id() {
+ let mut state = COMPONENT_STATE();
+ let uri = CONTRACT_URI();
+ state.initializer(uri);
+
+ let expected = format!("{}{}", uri, SAMPLE_ID);
+ assert_eq!(state.token_uri(SAMPLE_ID), expected);
+}
+
+#[test]
+fn test_token_uri_when_contract_uri_not_set_is_empty() {
+ let state = COMPONENT_STATE();
+ let empty: ByteArray = "";
+ assert_eq!(state.token_uri(SAMPLE_ID), empty);
+}
diff --git a/packages/token/src/tests/erc6909/test_erc6909_metadata.cairo b/packages/token/src/tests/erc6909/test_erc6909_metadata.cairo
new file mode 100644
index 000000000..541c5442b
--- /dev/null
+++ b/packages/token/src/tests/erc6909/test_erc6909_metadata.cairo
@@ -0,0 +1,109 @@
+use openzeppelin_interfaces::erc6909::IERC6909_METADATA_ID;
+use openzeppelin_interfaces::introspection::ISRC5_ID;
+use openzeppelin_introspection::src5::SRC5Component::SRC5Impl;
+use openzeppelin_test_common::mocks::erc6909::ERC6909MetadataMock;
+use openzeppelin_testing::constants::{DECIMALS, NAME, OWNER, SYMBOL, TOKEN_ID, ZERO};
+use starknet::ContractAddress;
+use crate::erc6909::extensions::erc6909_metadata::ERC6909MetadataComponent;
+use crate::erc6909::extensions::erc6909_metadata::ERC6909MetadataComponent::{
+ ERC6909MetadataImpl, InternalImpl,
+};
+
+type ComponentState = ERC6909MetadataComponent::ComponentState;
+
+fn CONTRACT_STATE() -> ERC6909MetadataMock::ContractState {
+ ERC6909MetadataMock::contract_state_for_testing()
+}
+
+fn COMPONENT_STATE() -> ComponentState {
+ ERC6909MetadataComponent::component_state_for_testing()
+}
+
+fn NAME_2() -> ByteArray {
+ "ALT_NAME"
+}
+fn SYMBOL_2() -> ByteArray {
+ "ALT_SYMBOL"
+}
+const DECIMALS_2: u8 = 6;
+
+#[test]
+fn test_initializer_registers_interface() {
+ let mut state = COMPONENT_STATE();
+ let mock_state = CONTRACT_STATE();
+
+ state.initializer();
+
+ assert!(mock_state.supports_interface(IERC6909_METADATA_ID));
+ assert!(mock_state.supports_interface(ISRC5_ID));
+}
+
+#[test]
+fn test_default_getters_are_empty_or_zero() {
+ let state = COMPONENT_STATE();
+
+ let empty: ByteArray = "";
+ assert_eq!(state.name(TOKEN_ID), empty);
+ assert_eq!(state.symbol(TOKEN_ID), empty);
+ assert_eq!(state.decimals(TOKEN_ID), 0);
+}
+
+#[test]
+fn test__set_token_metadata_sets_values() {
+ let mut state = COMPONENT_STATE();
+
+ state._set_token_metadata(TOKEN_ID, NAME(), SYMBOL(), DECIMALS);
+
+ assert_eq!(state.name(TOKEN_ID), NAME());
+ assert_eq!(state.symbol(TOKEN_ID), SYMBOL());
+ assert_eq!(state.decimals(TOKEN_ID), DECIMALS);
+}
+
+#[test]
+fn test__update_token_metadata_on_mint_sets_when_absent() {
+ let mut state = COMPONENT_STATE();
+
+ state._update_token_metadata(ZERO, TOKEN_ID, NAME(), SYMBOL(), DECIMALS);
+
+ assert_eq!(state.name(TOKEN_ID), NAME());
+ assert_eq!(state.symbol(TOKEN_ID), SYMBOL());
+ assert_eq!(state.decimals(TOKEN_ID), DECIMALS);
+}
+
+#[test]
+fn test__update_token_metadata_on_mint_does_not_overwrite_if_exists() {
+ let mut state = COMPONENT_STATE();
+
+ state._set_token_metadata(TOKEN_ID, NAME(), SYMBOL(), DECIMALS);
+ state._update_token_metadata(ZERO, TOKEN_ID, NAME_2(), SYMBOL_2(), DECIMALS_2);
+
+ assert_eq!(state.name(TOKEN_ID), NAME());
+ assert_eq!(state.symbol(TOKEN_ID), SYMBOL());
+ assert_eq!(state.decimals(TOKEN_ID), DECIMALS);
+}
+
+#[test]
+fn test__update_token_metadata_on_transfer_does_nothing_when_absent() {
+ let mut state = COMPONENT_STATE();
+ let sender: ContractAddress = OWNER;
+
+ state._update_token_metadata(sender, TOKEN_ID, NAME(), SYMBOL(), DECIMALS);
+
+ let empty: ByteArray = "";
+ assert_eq!(state.name(TOKEN_ID), empty);
+ assert_eq!(state.symbol(TOKEN_ID), empty);
+ assert_eq!(state.decimals(TOKEN_ID), 0);
+}
+
+#[test]
+fn test__update_token_metadata_on_transfer_does_not_overwrite_existing() {
+ let mut state = COMPONENT_STATE();
+ let sender: ContractAddress = OWNER;
+
+ state._set_token_metadata(TOKEN_ID, NAME(), SYMBOL(), DECIMALS);
+ state._update_token_metadata(sender, TOKEN_ID, NAME_2(), SYMBOL_2(), DECIMALS_2);
+
+ assert_eq!(state.name(TOKEN_ID), NAME());
+ assert_eq!(state.symbol(TOKEN_ID), SYMBOL());
+ assert_eq!(state.decimals(TOKEN_ID), DECIMALS);
+}
diff --git a/packages/token/src/tests/erc6909/test_erc6909_token_supply.cairo b/packages/token/src/tests/erc6909/test_erc6909_token_supply.cairo
new file mode 100644
index 000000000..6743254eb
--- /dev/null
+++ b/packages/token/src/tests/erc6909/test_erc6909_token_supply.cairo
@@ -0,0 +1,74 @@
+use openzeppelin_interfaces::erc6909::IERC6909_TOKEN_SUPPLY_ID;
+use openzeppelin_interfaces::introspection::ISRC5_ID;
+use openzeppelin_introspection::src5::SRC5Component::SRC5Impl;
+use openzeppelin_test_common::mocks::erc6909::ERC6909TokenSupplyMock;
+use openzeppelin_testing::constants::{OWNER, RECIPIENT, TOKEN_ID, VALUE, ZERO};
+use starknet::ContractAddress;
+use crate::erc6909::extensions::erc6909_token_supply::ERC6909TokenSupplyComponent;
+use crate::erc6909::extensions::erc6909_token_supply::ERC6909TokenSupplyComponent::{
+ ERC6909TokenSupplyImpl, InternalImpl,
+};
+
+type ComponentState =
+ ERC6909TokenSupplyComponent::ComponentState;
+
+fn CONTRACT_STATE() -> ERC6909TokenSupplyMock::ContractState {
+ ERC6909TokenSupplyMock::contract_state_for_testing()
+}
+
+fn COMPONENT_STATE() -> ComponentState {
+ ERC6909TokenSupplyComponent::component_state_for_testing()
+}
+
+#[test]
+fn test_initializer_registers_interface() {
+ let mut state = COMPONENT_STATE();
+ let mock_state = CONTRACT_STATE();
+
+ state.initializer();
+
+ assert!(mock_state.supports_interface(IERC6909_TOKEN_SUPPLY_ID));
+ assert!(mock_state.supports_interface(ISRC5_ID));
+}
+
+#[test]
+fn test_total_supply_default_zero() {
+ let state = COMPONENT_STATE();
+ assert_eq!(state.total_supply(TOKEN_ID), 0);
+}
+
+#[test]
+fn test__update_token_supply_increments_on_mint() {
+ let mut state = COMPONENT_STATE();
+ let before = state.total_supply(TOKEN_ID);
+
+ state._update_token_supply(ZERO, RECIPIENT, TOKEN_ID, VALUE);
+
+ assert_eq!(state.total_supply(TOKEN_ID), before + VALUE);
+}
+
+#[test]
+fn test__update_token_supply_decrements_on_burn() {
+ let mut state = COMPONENT_STATE();
+
+ state._update_token_supply(ZERO, RECIPIENT, TOKEN_ID, VALUE + 1);
+ let mid = state.total_supply(TOKEN_ID);
+
+ state._update_token_supply(OWNER, ZERO, TOKEN_ID, 1);
+
+ assert_eq!(state.total_supply(TOKEN_ID), mid - 1);
+}
+
+#[test]
+fn test__update_token_supply_no_change_on_transfer() {
+ let mut state = COMPONENT_STATE();
+
+ state._update_token_supply(ZERO, RECIPIENT, TOKEN_ID, VALUE);
+ let before = state.total_supply(TOKEN_ID);
+
+ let sender: ContractAddress = OWNER;
+ let receiver: ContractAddress = RECIPIENT;
+ state._update_token_supply(sender, receiver, TOKEN_ID, VALUE);
+
+ assert_eq!(state.total_supply(TOKEN_ID), before);
+}