Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ERC721 URI Storage #1031

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@

### Added

<<<<<<< HEAD
- TimelockController component (#996)

Check failure on line 14 in CHANGELOG.md

View workflow job for this annotation

GitHub Actions / Lint and test

Lists should be surrounded by blank lines

CHANGELOG.md:14 MD032/blanks-around-lists Lists should be surrounded by blank lines [Context: "- TimelockController component..."] https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md032.md
- HashCall implementation (#996)
- Separated package for each submodule (#1065)
- `openzeppelin_access`
Expand All @@ -22,6 +23,9 @@
- `openzeppelin_token`
- `openzeppelin_upgrades`
- `openzeppelin_utils`
=======
- ERC721URIStorage (#1031)

Check failure on line 27 in CHANGELOG.md

View workflow job for this annotation

GitHub Actions / Lint and test

Lists should be surrounded by blank lines

CHANGELOG.md:27 MD032/blanks-around-lists Lists should be surrounded by blank lines [Context: "- ERC721URIStorage (#1031)"] https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md032.md
>>>>>>> e4e7368 (updated cairo version)

### Changed

Expand Down
96 changes: 96 additions & 0 deletions docs/modules/ROOT/pages/api/erc721.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,102 @@ See <<IERC721-ApprovalForAll,IERC721::ApprovalForAll>>.

See <<IERC721-Transfer,IERC721::Transfer>>.

== 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 <<IERC721Metadata, IERC721Metadata>> 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 <<IERC721Metadata-name,IERC721::name>>.

[.contract-item]
[[ERC721URIStorageComponent-symbol]]
==== `[.contract-item-name]#++symbol++#++(self: @ContractState) → ByteArray++` [.item-kind]#external#

See <<IERC721Metadata-symbol,IERC721::symbol>>.

[.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]
Expand Down
1 change: 1 addition & 0 deletions packages/presets/src/tests/mocks.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -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;
1 change: 1 addition & 0 deletions packages/token/src/erc721.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions packages/token/src/erc721/extensions.cairo
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub mod erc721_uri_storage;

pub use erc721_uri_storage::ERC721URIStorageComponent;
103 changes: 103 additions & 0 deletions packages/token/src/erc721/extensions/erc721_uri_storage.cairo
Original file line number Diff line number Diff line change
@@ -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<u256, ByteArray>,
}

#[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<TContractState>,
+SRC5Component::HasComponent<TContractState>,
impl ERC721: ERC721Component::HasComponent<TContractState>,
+Drop<TContractState>
> of IERC721Metadata<ComponentState<TContractState>> {
/// Returns the NFT name.
fn name(self: @ComponentState<TContractState>) -> ByteArray {
let erc721_component = get_dep_component!(self, ERC721);
erc721_component.ERC721_name.read()
}

/// Returns the NFT symbol.
fn symbol(self: @ComponentState<TContractState>) -> 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<TContractState>, 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<TContractState>,
+SRC5Component::HasComponent<TContractState>,
+ERC721Component::HasComponent<TContractState>,
+Drop<TContractState>
> of InternalTrait<TContractState> {
/// Sets or updates the `token_uri` for the respective `token_id`.
///
/// Emits `MetadataUpdated` event.
fn set_token_uri(
ref self: ComponentState<TContractState>, token_id: u256, token_uri: ByteArray
) {
self.ERC721URIStorage_token_uris.write(token_id, token_uri);
self.emit(MetadataUpdated { token_id: token_id });
}
}
}
153 changes: 153 additions & 0 deletions packages/token/src/tests/erc721/test_erc721_uri_storage.cairo
Original file line number Diff line number Diff line change
@@ -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<ERC721URIStorageMock::ContractState>;

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::<ERC721URIStorageComponent::Event>(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);
}
Loading
Loading