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

feat: add ERC2981 component #1043

Closed
1 change: 1 addition & 0 deletions src/tests/mocks.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub(crate) mod erc1155_mocks;
pub(crate) mod erc1155_receiver_mocks;
pub(crate) mod erc20_mocks;
pub(crate) mod erc20_votes_mocks;
pub(crate) mod erc2981_mocks;
pub(crate) mod erc721_mocks;
pub(crate) mod erc721_receiver_mocks;
pub(crate) mod eth_account_mocks;
Expand Down
45 changes: 45 additions & 0 deletions src/tests/mocks/erc2981_mocks.cairo
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#[starknet::contract]
pub(crate) mod ERC2981Mock {
use openzeppelin::introspection::src5::SRC5Component;
use openzeppelin::token::common::erc2981::ERC2981Component;
use starknet::ContractAddress;

component!(path: ERC2981Component, storage: erc2981, event: ERC2981Event);
component!(path: SRC5Component, storage: src5, event: SRC5Event);

#[storage]
struct Storage {
#[substorage(v0)]
erc2981: ERC2981Component::Storage,
#[substorage(v0)]
src5: SRC5Component::Storage
}

#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
ERC2981Event: ERC2981Component::Event,
#[flat]
SRC5Event: SRC5Component::Event
}


#[abi(embed_v0)]
ericnordelo marked this conversation as resolved.
Show resolved Hide resolved
impl ERC2981Impl = ERC2981Component::ERC2981Impl<ContractState>;
impl ERC2981InternalImpl = ERC2981Component::InternalImpl<ContractState>;

// SRC5
#[abi(embed_v0)]
impl SRC5Impl = SRC5Component::SRC5Impl<ContractState>;

#[constructor]
fn constructor(
ref self: ContractState,
owner: ContractAddress,
default_receiver: ContractAddress,
default_royalty_fraction: u256
) {
self.erc2981.initializer(default_receiver, default_royalty_fraction);
}
}
1 change: 1 addition & 0 deletions src/tests/token.cairo
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub(crate) mod erc1155;
pub(crate) mod erc20;
pub(crate) mod erc2981;
pub(crate) mod erc721;
1 change: 1 addition & 0 deletions src/tests/token/erc2981.cairo
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
mod test_erc2981;
90 changes: 90 additions & 0 deletions src/tests/token/erc2981/test_erc2981.cairo
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
use openzeppelin::introspection::interface::{ISRC5Dispatcher, ISRC5DispatcherTrait};

use openzeppelin::tests::mocks::erc2981_mocks::ERC2981Mock;
use openzeppelin::token::common::erc2981::ERC2981Component::{ERC2981Impl, InternalImpl};
use openzeppelin::token::common::erc2981::ERC2981Component;
use openzeppelin::token::common::erc2981::interface::IERC2981_ID;
use openzeppelin::token::common::erc2981::{IERC2981Dispatcher, IERC2981DispatcherTrait};

use starknet::{ContractAddress, contract_address_const};


type ComponentState = ERC2981Component::ComponentState<ERC2981Mock::ContractState>;

fn COMPONENT_STATE() -> ComponentState {
ERC2981Component::component_state_for_testing()
}

fn OWNER() -> ContractAddress {
contract_address_const::<'OWNER'>()
}

fn DEFAULT_RECEIVER() -> ContractAddress {
contract_address_const::<'DEFAULT_RECEIVER'>()
}

fn RECEIVER() -> ContractAddress {
contract_address_const::<'RECEIVER'>()
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constants module already declares similar constants, you can employ them instead


// 0.5% (default denominator is 10000)
fn DEFAULT_FEE_NUMERATOR() -> u256 {
50
}

// 5% (default denominator is 10000)
fn FEE_NUMERATOR() -> u256 {
500
}
immrsd marked this conversation as resolved.
Show resolved Hide resolved

fn setup() -> ComponentState {
let mut state = COMPONENT_STATE();
state.initializer(DEFAULT_RECEIVER(), DEFAULT_FEE_NUMERATOR());
state
}


#[test]
fn test_default_royalty() {
let mut state = setup();
let token_id = 12;
let sale_price = 1_000_000;
let (receiver, amount) = state.royalty_info(token_id, sale_price);
assert_eq!(receiver, DEFAULT_RECEIVER(), "Default receiver incorrect");
assert_eq!(amount, 5000, "Default fees incorrect");

state._set_default_royalty(RECEIVER(), FEE_NUMERATOR());

let (receiver, amount) = state.royalty_info(token_id, sale_price);
assert_eq!(receiver, RECEIVER(), "Default receiver incorrect");
assert_eq!(amount, 50000, "Default fees incorrect");
}


#[test]
fn test_token_royalty_token() {
let mut state = setup();
let token_id = 12;
let another_token_id = 13;
let sale_price = 1_000_000;
let (receiver, amount) = state.royalty_info(token_id, sale_price);
assert_eq!(receiver, DEFAULT_RECEIVER(), "Default receiver incorrect");
assert_eq!(amount, 5000, "Wrong royalty amount");
let (receiver, amount) = state.royalty_info(another_token_id, sale_price);
assert_eq!(receiver, DEFAULT_RECEIVER(), "Default receiver incorrect");
assert_eq!(amount, 5000, "Wrong royalty amount");

state._set_token_royalty(token_id, RECEIVER(), FEE_NUMERATOR());
let (receiver, amount) = state.royalty_info(another_token_id, sale_price);
assert_eq!(receiver, DEFAULT_RECEIVER(), "Default receiver incorrect");
assert_eq!(amount, 5000, "Wrong royalty amount");
let (receiver, amount) = state.royalty_info(token_id, sale_price);
assert_eq!(receiver, RECEIVER(), "Default receiver incorrect");
assert_eq!(amount, 50000, "Wrong royalty amount");

state._reset_token_royalty(token_id);
let (receiver, amount) = state.royalty_info(token_id, sale_price);
assert_eq!(receiver, DEFAULT_RECEIVER(), "Default receiver incorrect");
assert_eq!(amount, 5000, "Wrong royalty amount");
}
immrsd marked this conversation as resolved.
Show resolved Hide resolved

3 changes: 3 additions & 0 deletions src/token.cairo
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
pub mod erc1155;
pub mod erc20;
pub mod erc721;
pub mod common {
pub mod erc2981;
}
5 changes: 5 additions & 0 deletions src/token/common/erc2981.cairo
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pub mod erc2981;
pub mod interface;

pub use erc2981::ERC2981Component;
pub use interface::{IERC2981Dispatcher, IERC2981DispatcherTrait};
165 changes: 165 additions & 0 deletions src/token/common/erc2981/erc2981.cairo
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// SPDX-License-Identifier: MIT

/// #ERC2981 Component
///
/// The ERC2981 compononet provides an implementation of the IERC2981 interface.
#[starknet::component]
pub mod ERC2981Component {
use core::num::traits::Zero;

use openzeppelin::introspection::src5::SRC5Component::InternalTrait as SRC5InternalTrait;
use openzeppelin::introspection::src5::SRC5Component::SRC5Impl;
use openzeppelin::introspection::src5::SRC5Component;
use openzeppelin::token::common::erc2981::interface::{IERC2981, IERC2981_ID};

use starknet::ContractAddress;
use starknet::storage::Map;

#[derive(Serde, Drop, PartialEq, starknet::Store)]
struct RoyaltyInfo {
pub receiver: ContractAddress,
pub royalty_fraction: u256,
}

#[storage]
struct Storage {
default_royalty_info: RoyaltyInfo,
token_royalty_info: Map<u256, RoyaltyInfo>,
}

//
// External
//
#[embeddable_as(ERC2981Impl)]
immrsd marked this conversation as resolved.
Show resolved Hide resolved
impl ERC2981<
TContractState,
+HasComponent<TContractState>,
impl SRC5: SRC5Component::HasComponent<TContractState>,
+Drop<TContractState>,
> of IERC2981<ComponentState<TContractState>> {
/// Returns the receiver address and amount to send for royalty
/// for token id `token_id` and sale price `sale_price`
fn royalty_info(
self: @ComponentState<TContractState>, token_id: u256, sale_price: u256
) -> (ContractAddress, u256) {
let royalty_info: RoyaltyInfo = self.token_royalty_info.read(token_id);
immrsd marked this conversation as resolved.
Show resolved Hide resolved
let mut royalty_receiver = royalty_info.receiver;
let mut royalty_fraction = royalty_info.royalty_fraction;

if royalty_receiver.is_zero() {
let default_royalty_info: RoyaltyInfo = self.default_royalty_info.read();
royalty_receiver = default_royalty_info.receiver;
royalty_fraction = default_royalty_info.royalty_fraction;
}
immrsd marked this conversation as resolved.
Show resolved Hide resolved

let royalty_amount: u256 = (sale_price * royalty_fraction) / self._fee_denominator();
immrsd marked this conversation as resolved.
Show resolved Hide resolved

(royalty_receiver, royalty_amount)
}
}


//
// Internal
//
#[generate_trait]
immrsd marked this conversation as resolved.
Show resolved Hide resolved
pub impl InternalImpl<
TContractState,
+HasComponent<TContractState>,
impl SRC5: SRC5Component::HasComponent<TContractState>,
+Drop<TContractState>
> of InternalTrait<TContractState> {
/// Initializes the contract by setting default royalty.
fn initializer(
ref self: ComponentState<TContractState>,
default_receiver: ContractAddress,
default_royalty_fraction: u256
) {
let mut src5_component = get_dep_component_mut!(ref self, SRC5);
src5_component.register_interface(IERC2981_ID);

self._set_default_royalty(default_receiver, default_royalty_fraction)
}


/// The denominator with which to interpret the fee set in {_set_token_royalty} and
/// {_set_default_royalty} as a fraction of the sale price.
/// Defaults to 10000 so fees are expressed in basis points
fn _fee_denominator(self: @ComponentState<TContractState>) -> u256 {
10000
immrsd marked this conversation as resolved.
Show resolved Hide resolved
}

/// Returns the royalty information that all ids in this contract will default to.
fn _default_royalty(
self: @ComponentState<TContractState>
) -> (ContractAddress, u256, u256) {
let royalty_info: RoyaltyInfo = self.default_royalty_info.read();
immrsd marked this conversation as resolved.
Show resolved Hide resolved
(royalty_info.receiver, royalty_info.royalty_fraction, self._fee_denominator())
}

/// Sets the royalty information that all ids in this contract will default to.
///
/// Requirements:
///
/// - `receiver` cannot be the zero address.
/// - `fee_numerator` cannot be greater than the fee denominator.
fn _set_default_royalty(
ref self: ComponentState<TContractState>,
receiver: ContractAddress,
fee_numerator: u256,
) {
let denominator = self._fee_denominator();
assert!(fee_numerator <= denominator, "Invalid default royalty");
assert!(!receiver.is_zero(), "Invalid default royalty receiver");
immrsd marked this conversation as resolved.
Show resolved Hide resolved
self
.default_royalty_info
.write(RoyaltyInfo { receiver, royalty_fraction: fee_numerator })
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation lacks _deleteDefaultRoyalty function, which is present in the Solidity implementation. Imo, it makes sense to add it for consistency

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please confirm if the following behavior is expected after _deleteDefaultRoyalty is called ?:

  • Default Royalty is 0% and receiver address is address(0)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is the expected one


/// Sets the royalty information for a specific token id, overriding the global default.
///
/// Requirements:
///
/// - `receiver` cannot be the zero address.
/// - `fee_numerator` cannot be greater than the fee denominator.
fn _set_token_royalty(
ref self: ComponentState<TContractState>,
token_id: u256,
receiver: ContractAddress,
fee_numerator: u256
) {
let denominator = self._fee_denominator();
assert!(fee_numerator <= denominator, "Invalid token royalty");
assert!(!receiver.is_zero(), "Invalid token royalty receiver");

self
.token_royalty_info
.write(token_id, RoyaltyInfo { receiver, royalty_fraction: fee_numerator },)
}

/// Returns the royalty information that all ids in this contract will default to.
fn _token_royalty(
self: @ComponentState<TContractState>, token_id: u256
) -> (ContractAddress, u256, u256) {
let royalty_info: RoyaltyInfo = self.token_royalty_info.read(token_id);
let mut receiver = royalty_info.receiver;
let mut royalty_fraction = royalty_info.royalty_fraction;

if receiver.is_zero() {
let default_royalty_info: RoyaltyInfo = self.default_royalty_info.read();
receiver = default_royalty_info.receiver;
royalty_fraction = default_royalty_info.royalty_fraction;
};
immrsd marked this conversation as resolved.
Show resolved Hide resolved
(receiver, royalty_fraction, self._fee_denominator())
}


/// Resets royalty information for the token id back to the global default.
fn _reset_token_royalty(ref self: ComponentState<TContractState>, token_id: u256) {
self
.token_royalty_info
.write(token_id, RoyaltyInfo { receiver: Zero::zero(), royalty_fraction: 0 },)
}
}
}
8 changes: 8 additions & 0 deletions src/token/common/erc2981/interface.cairo
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
use starknet::ContractAddress;

pub const IERC2981_ID: felt252 = 0x2d3414e45a8700c29f119a54b9f11dca0e29e06ddcb214018fc37340e165ed6;
immrsd marked this conversation as resolved.
Show resolved Hide resolved

#[starknet::interface]
pub trait IERC2981<TState> {
fn royalty_info(self: @TState, token_id: u256, sale_price: u256) -> (ContractAddress, u256);
}
Loading