diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b941d27..5bb9333d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +<<<<<<< HEAD - TimelockController component (#996) - HashCall implementation (#996) - Separated package for each submodule (#1065) @@ -22,6 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `openzeppelin_token` - `openzeppelin_upgrades` - `openzeppelin_utils` +======= +- ERC721URIStorage (#1031) +>>>>>>> e4e7368 (updated cairo version) ### Changed diff --git a/docs/modules/ROOT/pages/api/erc721.adoc b/docs/modules/ROOT/pages/api/erc721.adoc index 09ab75d29..7162bf8be 100644 --- a/docs/modules/ROOT/pages/api/erc721.adoc +++ b/docs/modules/ROOT/pages/api/erc721.adoc @@ -689,6 +689,102 @@ See <>. See <>. +== Extensions + +[.contract] +[[ERC721URIStorageComponent]] +=== `++ERC721URIStorageComponent++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v0.15.0-rc.0/src/token/erc721/extensions/erc721_uri_storage.cairo[{github-icon},role=heading-link] + +```cairo +use openzeppelin::token::extensions::ERC721URIStorageComponent; +``` + +:MetadataUpdated: xref:ERC721URIStorageComponent-MetadataUpdated[MetadataUpdated] + +Extension of ERC721 to support storage-based URI management. +It is an implementation of <> but with a different `token_uri` behavior. + +NOTE: Implementing xref:#ERC721Component[ERC721Component] is a requirement for this component to be implemented. + +The ERC721URIStorage component provides a flexible IERC721Metadata implementation that enables storage-based token URI management. +The URI of any `token_id` can be set by calling the internal function, xref:#ERC721URIStorageComponent-set_token_uri[set_token_uri]. +The updated URI can be queried through the external function xref:#ERC721URIStorageComponent-token_uri[token_uri]. + +[.contract-index#ERC721URIStorageComponent-Embeddable-Impls] +.Embeddable Implementations +-- +[.sub-index#ERC721URIStorageComponent-Embeddable-Impls-ERC721URIStorageImpl] +.ERC721URIStorageImpl +* xref:#ERC721URIStorageComponent-name[`++name(self)++`] +* xref:#ERC721URIStorageComponent-symbol[`++symbol(self)++`] +* xref:#ERC721URIStorageComponent-token_uri[`++token_uri(self, token_id)++`] + +-- + +[.contract-index] +.Internal implementations +-- +.InternalImpl +* xref:#ERC721URIStorageComponent-set_token_uri[`++set_token_uri(self, token_id, token_uri)++`] + +-- + +[.contract-index] +.Events +-- +* xref:#ERC721URIStorageComponent-MetadataUpdated[`++MetadataUpdated(token_id)++`] +-- + +[#ERC721URIStorageComponent-Embeddable-functions] +==== Embeddable functions + +[.contract-item] +[[ERC721URIStorageComponent-name]] +==== `[.contract-item-name]#++name++#++(self: @ContractState) → ByteArray++` [.item-kind]#external# + +See <>. + +[.contract-item] +[[ERC721URIStorageComponent-symbol]] +==== `[.contract-item-name]#++symbol++#++(self: @ContractState) → ByteArray++` [.item-kind]#external# + +See <>. + +[.contract-item] +[[ERC721URIStorageComponent-token_uri]] +==== `[.contract-item-name]#++token_uri++#++(self: @ContractState, token_id : u256) → ByteArray++` [.item-kind]#external# + +Returns the Uniform Resource Identifier (URI) for the `token_id` token. + + +If a base URI is set and the token URI is set, the resulting URI for each token will be the concatenation of the base URI and the token URI. + +If a base URI is set and the token URI is not set, the resulting URI for each token will be the concatenation of the base URI and the `token_id`. + +If a base URI is not set and the token URI is set, the resulting URI for each token will be the token URI. + +If the base URI and token URI are not set for `token_id`, the return value will be an empty `ByteArray`. + +[#ERC721URIStorageComponent-Internal-functions] +==== Internal functions + +[.contract-item] +[[ERC721URIStorageComponent-set_token_uri]] +==== `[.contract-item-name]#++set_token_uri++#++(ref self: ContractState, token_id: u256, token_uri: ByteArray)++` [.item-kind]#internal# + +Sets the `token_uri` of `token_id`. + +Emits a {MetadataUpdated} event. + +[#ERC721URIStorageComponent-Events] +==== Events + +[.contract-item] +[[ERC721URIStorageComponent-MetadataUpdated]] +==== `[.contract-item-name]#++MetadataUpdated++#++(token_id: u256)++` [.item-kind]#event# + +Emitted when the URI of `token_id` is set. + == Receiver [.contract] diff --git a/packages/presets/src/tests/mocks.cairo b/packages/presets/src/tests/mocks.cairo index 2037caeeb..2a2959d15 100644 --- a/packages/presets/src/tests/mocks.cairo +++ b/packages/presets/src/tests/mocks.cairo @@ -4,6 +4,7 @@ pub(crate) mod erc1155_receiver_mocks; pub(crate) mod erc20_mocks; pub(crate) mod erc721_mocks; pub(crate) mod erc721_receiver_mocks; +pub(crate) mod erc721_uri_storage_mocks; pub(crate) mod eth_account_mocks; pub(crate) mod non_implementing_mock; pub(crate) mod src5_mocks; diff --git a/packages/token/src/erc721.cairo b/packages/token/src/erc721.cairo index 028fdcdeb..28c210486 100644 --- a/packages/token/src/erc721.cairo +++ b/packages/token/src/erc721.cairo @@ -2,6 +2,7 @@ pub mod dual721; pub mod dual721_receiver; pub mod erc721; pub mod erc721_receiver; +pub mod extensions; pub mod interface; pub use erc721::ERC721Component; diff --git a/packages/token/src/erc721/extensions.cairo b/packages/token/src/erc721/extensions.cairo new file mode 100644 index 000000000..7868b6f1e --- /dev/null +++ b/packages/token/src/erc721/extensions.cairo @@ -0,0 +1,3 @@ +pub mod erc721_uri_storage; + +pub use erc721_uri_storage::ERC721URIStorageComponent; diff --git a/packages/token/src/erc721/extensions/erc721_uri_storage.cairo b/packages/token/src/erc721/extensions/erc721_uri_storage.cairo new file mode 100644 index 000000000..07de94c59 --- /dev/null +++ b/packages/token/src/erc721/extensions/erc721_uri_storage.cairo @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.15.0-rc.0 (token/erc721/extensions/erc721_uri_storage.cairo) + +/// # ERC721URIStorage Component +/// +/// The ERC721URIStorage component provides a flexible IERC721Metadata implementation that enables +/// storage-based token URI management. +#[starknet::component] +pub mod ERC721URIStorageComponent { + use openzeppelin::introspection::src5::SRC5Component; + use openzeppelin::token::erc721::ERC721Component::InternalImpl as ERC721Impl; + use openzeppelin::token::erc721::interface::IERC721Metadata; + use openzeppelin::token::erc721::{ERC721Component, ERC721HooksEmptyImpl}; + use starknet::ContractAddress; + use starknet::storage::Map; + + #[storage] + struct Storage { + ERC721URIStorage_token_uris: Map, + } + + #[event] + #[derive(Drop, PartialEq, starknet::Event)] + pub enum Event { + MetadataUpdated: MetadataUpdated, + } + + /// Emitted when `token_uri` is changed for `token_id`. + #[derive(Drop, PartialEq, starknet::Event)] + pub struct MetadataUpdated { + pub token_id: u256, + } + + #[embeddable_as(ERC721URIStorageImpl)] + impl ERC721URIStorage< + TContractState, + +HasComponent, + +SRC5Component::HasComponent, + impl ERC721: ERC721Component::HasComponent, + +Drop + > of IERC721Metadata> { + /// Returns the NFT name. + fn name(self: @ComponentState) -> ByteArray { + let erc721_component = get_dep_component!(self, ERC721); + erc721_component.ERC721_name.read() + } + + /// Returns the NFT symbol. + fn symbol(self: @ComponentState) -> ByteArray { + let erc721_component = get_dep_component!(self, ERC721); + erc721_component.ERC721_symbol.read() + } + + + /// Returns the Uniform Resource Identifier (URI) for the `token_id` token. + /// + /// Requirements: + /// + /// - `token_id` exists. + fn token_uri(self: @ComponentState, token_id: u256) -> ByteArray { + let mut erc721_component = get_dep_component!(self, ERC721); + erc721_component._require_owned(token_id); + let base_uri: ByteArray = ERC721Impl::_base_uri(erc721_component); + let token_uri: ByteArray = self.ERC721URIStorage_token_uris.read(token_id); + + // If there is no base_uri, return the token_uri + if base_uri.len() == 0 { + return token_uri; + } + + // If both are set, concatenate the base_uri and token_uri + if token_uri.len() > 0 { + return format!("{}{}", base_uri, token_uri); + } + + // Implementation from ERC721Metadata::token_uri + return format!("{}{}", base_uri, token_id); + } + } + + // + // Internal + // + + #[generate_trait] + pub impl InternalImpl< + TContractState, + +HasComponent, + +SRC5Component::HasComponent, + +ERC721Component::HasComponent, + +Drop + > of InternalTrait { + /// Sets or updates the `token_uri` for the respective `token_id`. + /// + /// Emits `MetadataUpdated` event. + fn set_token_uri( + ref self: ComponentState, token_id: u256, token_uri: ByteArray + ) { + self.ERC721URIStorage_token_uris.write(token_id, token_uri); + self.emit(MetadataUpdated { token_id: token_id }); + } + } +} diff --git a/packages/token/src/tests/erc721/test_erc721_uri_storage.cairo b/packages/token/src/tests/erc721/test_erc721_uri_storage.cairo new file mode 100644 index 000000000..fb977b45e --- /dev/null +++ b/packages/token/src/tests/erc721/test_erc721_uri_storage.cairo @@ -0,0 +1,153 @@ +use openzeppelin::tests::mocks::erc721_uri_storage_mocks::ERC721URIStorageMock; +use openzeppelin::tests::utils::constants::{ + ZERO, OWNER, RECIPIENT, NAME, SYMBOL, TOKEN_ID, TOKEN_ID_2, BASE_URI, BASE_URI_2, SAMPLE_URI +}; +use openzeppelin::tests::utils; +use openzeppelin::token::erc721::ERC721Component::InternalImpl as ERC721InternalImpl; +use openzeppelin::token::erc721::extensions::ERC721URIStorageComponent::{ + ERC721URIStorageImpl, InternalImpl +}; +use openzeppelin::token::erc721::extensions::ERC721URIStorageComponent; +use openzeppelin::token::erc721::extensions::erc721_uri_storage::ERC721URIStorageComponent::MetadataUpdated; +use openzeppelin::utils::serde::SerializedAppend; +use starknet::ContractAddress; + + +// +// Setup +// + +type ComponentState = + ERC721URIStorageComponent::ComponentState; + +fn CONTRACT_STATE() -> ERC721URIStorageMock::ContractState { + ERC721URIStorageMock::contract_state_for_testing() +} +fn COMPONENT_STATE() -> ComponentState { + ERC721URIStorageComponent::component_state_for_testing() +} + +fn setup() -> ComponentState { + let mut state = COMPONENT_STATE(); + let mut mock_state = CONTRACT_STATE(); + mock_state.erc721.initializer(NAME(), SYMBOL(), ""); + mock_state.erc721.mint(OWNER(), TOKEN_ID); + utils::drop_event(ZERO()); + state +} + +#[test] +fn test_token_uri_when_not_set() { + let state = setup(); + let uri = state.token_uri(TOKEN_ID); + let empty = 0; + assert_eq!(uri.len(), empty); +} + +#[test] +#[should_panic(expected: ('ERC721: invalid token ID',))] +fn test_token_uri_non_minted() { + let state = setup(); + state.token_uri(TOKEN_ID_2); +} + +#[test] +fn test_set_token_uri() { + let mut state = setup(); + + state.set_token_uri(TOKEN_ID, SAMPLE_URI()); + assert_only_event_metadata_update(ZERO(), TOKEN_ID); + + let expected = SAMPLE_URI(); + let uri = state.token_uri(TOKEN_ID); + + assert_eq!(uri, expected); +} + +#[test] +fn test_set_token_uri_nonexistent() { + let mut state = setup(); + + state.set_token_uri(TOKEN_ID_2, SAMPLE_URI()); + assert_only_event_metadata_update(ZERO(), TOKEN_ID_2); + + let mut mock_contract_state = CONTRACT_STATE(); + // Check that the URI is accessible after minting + mock_contract_state.erc721.mint(RECIPIENT(), TOKEN_ID_2); + + let expected = SAMPLE_URI(); + let uri = state.token_uri(TOKEN_ID_2); + + assert_eq!(uri, expected); +} + +#[test] +fn test_token_uri_with_base_uri() { + let mut state = setup(); + + let mut mock_contract_state = CONTRACT_STATE(); + mock_contract_state.erc721._set_base_uri(BASE_URI()); + state.set_token_uri(TOKEN_ID, SAMPLE_URI()); + + let token_uri = state.token_uri(TOKEN_ID); + let expected = format!("{}{}", BASE_URI(), SAMPLE_URI()); + assert_eq!(token_uri, expected); +} + +#[test] +fn test_base_uri_2_is_set_as_prefix() { + let mut state = setup(); + + let mut mock_contract_state = CONTRACT_STATE(); + mock_contract_state.erc721._set_base_uri(BASE_URI()); + state.set_token_uri(TOKEN_ID, SAMPLE_URI()); + + mock_contract_state.erc721._set_base_uri(BASE_URI_2()); + + let token_uri = state.token_uri(TOKEN_ID); + let expected = format!("{}{}", BASE_URI_2(), SAMPLE_URI()); + assert_eq!(token_uri, expected); +} + +#[test] +fn test_token_uri_with_base_uri_and_token_id() { + let mut state = setup(); + + let mut mock_contract_state = CONTRACT_STATE(); + mock_contract_state.erc721._set_base_uri(BASE_URI()); + + let token_uri = state.token_uri(TOKEN_ID); + let expected = format!("{}{}", BASE_URI(), TOKEN_ID); + assert_eq!(token_uri, expected); +} + +#[test] +fn test_token_uri_persists_when_burned_and_minted() { + let mut state = setup(); + + state.set_token_uri(TOKEN_ID, SAMPLE_URI()); + + let mut mock_contract_state = CONTRACT_STATE(); + mock_contract_state.erc721.burn(TOKEN_ID); + + mock_contract_state.erc721.mint(OWNER(), TOKEN_ID); + + let token_uri = state.token_uri(TOKEN_ID); + let expected = SAMPLE_URI(); + assert_eq!(token_uri, expected); +} + +// +// Helpers +// + +fn assert_event_metadata_update(contract: ContractAddress, token_id: u256) { + let event = utils::pop_log::(contract).unwrap(); + let expected = ERC721URIStorageComponent::Event::MetadataUpdated(MetadataUpdated { token_id }); + assert!(event == expected); +} + +fn assert_only_event_metadata_update(contract: ContractAddress, token_id: u256) { + assert_event_metadata_update(contract, token_id); + utils::assert_no_events_left(contract); +} diff --git a/packages/token/src/tests/mocks/erc721_uri_storage_mocks.cairo b/packages/token/src/tests/mocks/erc721_uri_storage_mocks.cairo new file mode 100644 index 000000000..c8f054643 --- /dev/null +++ b/packages/token/src/tests/mocks/erc721_uri_storage_mocks.cairo @@ -0,0 +1,61 @@ +#[starknet::contract] +pub(crate) mod ERC721URIStorageMock { + use openzeppelin::introspection::src5::SRC5Component; + use openzeppelin::token::erc721::extensions::ERC721URIStorageComponent; + use openzeppelin::token::erc721::{ERC721Component, ERC721HooksEmptyImpl}; + use starknet::ContractAddress; + + component!( + path: ERC721URIStorageComponent, storage: erc721_uri_storage, event: ERC721URIStorageEvent + ); + component!(path: ERC721Component, storage: erc721, event: ERC721Event); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + + // ERC721URIStorage + #[abi(embed_v0)] + impl ERC721URIStorageImpl = + ERC721URIStorageComponent::ERC721URIStorageImpl; + + // ERC721 + #[abi(embed_v0)] + impl ERC721Impl = ERC721Component::ERC721Impl; + impl ERC721InternalImpl = ERC721Component::InternalImpl; + + // SRC5 + #[abi(embed_v0)] + impl SRC5Impl = SRC5Component::SRC5Impl; + + #[storage] + struct Storage { + #[substorage(v0)] + erc721_uri_storage: ERC721URIStorageComponent::Storage, + #[substorage(v0)] + erc721: ERC721Component::Storage, + #[substorage(v0)] + src5: SRC5Component::Storage + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + ERC721URIStorageEvent: ERC721URIStorageComponent::Event, + #[flat] + ERC721Event: ERC721Component::Event, + #[flat] + SRC5Event: SRC5Component::Event, + } + + #[constructor] + fn constructor( + ref self: ContractState, + name: ByteArray, + symbol: ByteArray, + base_uri: ByteArray, + recipient: ContractAddress, + token_id: u256 + ) { + self.erc721.initializer(name, symbol, base_uri); + self.erc721.mint(recipient, token_id); + } +} diff --git a/src/tests/token/erc721.cairo b/src/tests/token/erc721.cairo new file mode 100644 index 000000000..c48cd876b --- /dev/null +++ b/src/tests/token/erc721.cairo @@ -0,0 +1,6 @@ +pub(crate) mod common; + +mod test_dual721; +mod test_dual721_receiver; +mod test_erc721; +mod test_erc721_uri_storage; diff --git a/src/tests/utils/constants.cairo b/src/tests/utils/constants.cairo new file mode 100644 index 000000000..d91ec7f73 --- /dev/null +++ b/src/tests/utils/constants.cairo @@ -0,0 +1,120 @@ +use openzeppelin::account::interface::EthPublicKey; +use starknet::ClassHash; +use starknet::ContractAddress; +use starknet::SyscallResultTrait; +use starknet::class_hash::class_hash_const; +use starknet::contract_address_const; +use starknet::secp256_trait::Secp256Trait; + +pub(crate) const DECIMALS: u8 = 18_u8; +pub(crate) const SUPPLY: u256 = 2000; +pub(crate) const VALUE: u256 = 300; +pub(crate) const ROLE: felt252 = 'ROLE'; +pub(crate) const OTHER_ROLE: felt252 = 'OTHER_ROLE'; +pub(crate) const TOKEN_ID: u256 = 21; +pub(crate) const TOKEN_ID_2: u256 = 121; +pub(crate) const TOKEN_VALUE: u256 = 42; +pub(crate) const TOKEN_VALUE_2: u256 = 142; +pub(crate) const PUBKEY: felt252 = 'PUBKEY'; +pub(crate) const NEW_PUBKEY: felt252 = + 0x26da8d11938b76025862be14fdb8b28438827f73e75e86f7bfa38b196951fa7; +pub(crate) const DAPP_NAME: felt252 = 'DAPP_NAME'; +pub(crate) const DAPP_VERSION: felt252 = 'DAPP_VERSION'; +pub(crate) const SALT: felt252 = 'SALT'; +pub(crate) const SUCCESS: felt252 = 123123; +pub(crate) const FAILURE: felt252 = 456456; +pub(crate) const MIN_TRANSACTION_VERSION: felt252 = 1; +// 2**128 +pub(crate) const QUERY_OFFSET: felt252 = 0x100000000000000000000000000000000; +// QUERY_OFFSET + MIN_TRANSACTION_VERSION +pub(crate) const QUERY_VERSION: felt252 = 0x100000000000000000000000000000001; + +pub(crate) fn NAME() -> ByteArray { + "NAME" +} + +pub(crate) fn SYMBOL() -> ByteArray { + "SYMBOL" +} + +pub(crate) fn BASE_URI() -> ByteArray { + "https://api.example.com/v1/" +} + +pub(crate) fn BASE_URI_2() -> ByteArray { + "https://api.example.com/v2/" +} + +pub(crate) fn SAMPLE_URI() -> ByteArray { + "mock://mytoken" +} + +pub(crate) fn ETH_PUBKEY() -> EthPublicKey { + Secp256Trait::secp256_ec_get_point_from_x_syscall(3, false).unwrap_syscall().unwrap() +} + +pub(crate) fn NEW_ETH_PUBKEY() -> EthPublicKey { + Secp256Trait::secp256_ec_get_point_from_x_syscall(4, false).unwrap_syscall().unwrap() +} + +pub(crate) fn ADMIN() -> ContractAddress { + contract_address_const::<'ADMIN'>() +} + +pub(crate) fn AUTHORIZED() -> ContractAddress { + contract_address_const::<'AUTHORIZED'>() +} + +pub(crate) fn ZERO() -> ContractAddress { + contract_address_const::<0>() +} + +pub(crate) fn CLASS_HASH_ZERO() -> ClassHash { + class_hash_const::<0>() +} + +pub(crate) fn CALLER() -> ContractAddress { + contract_address_const::<'CALLER'>() +} + +pub(crate) fn OWNER() -> ContractAddress { + contract_address_const::<'OWNER'>() +} + +pub(crate) fn NEW_OWNER() -> ContractAddress { + contract_address_const::<'NEW_OWNER'>() +} + +pub(crate) fn OTHER() -> ContractAddress { + contract_address_const::<'OTHER'>() +} + +pub(crate) fn OTHER_ADMIN() -> ContractAddress { + contract_address_const::<'OTHER_ADMIN'>() +} + +pub(crate) fn SPENDER() -> ContractAddress { + contract_address_const::<'SPENDER'>() +} + +pub(crate) fn RECIPIENT() -> ContractAddress { + contract_address_const::<'RECIPIENT'>() +} + +pub(crate) fn OPERATOR() -> ContractAddress { + contract_address_const::<'OPERATOR'>() +} + +pub(crate) fn DATA(success: bool) -> Span { + let mut data = array![]; + if success { + data.append(SUCCESS); + } else { + data.append(FAILURE); + } + data.span() +} + +pub(crate) fn EMPTY_DATA() -> Span { + array![].span() +}