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