├── .github └── workflows │ ├── build.yml │ └── test.yml ├── .gitignore ├── .tool-versions ├── LICENSE ├── README.md ├── Scarb.lock ├── Scarb.toml └── src ├── components └── erc2981.cairo ├── interfaces └── erc2981.cairo ├── lib.cairo ├── mocks └── erc2981.cairo ├── presets └── erc721_royalty.cairo └── tests └── test_erc721_royalty.cairo /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | pull_request: 5 | jobs: 6 | check: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: software-mansion/setup-scarb@v1 11 | with: 12 | scarb-version: "2.6.5" 13 | - name: Cairo lint 14 | run: scarb fmt --check 15 | - name: Cairo build 16 | run: scarb build 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | pull_request: 5 | jobs: 6 | check: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout repository 10 | uses: actions/checkout@v3 11 | 12 | - name: Setup Scarb 13 | uses: software-mansion/setup-scarb@v1 14 | with: 15 | scarb-version: "2.6.5" 16 | 17 | - name: Setup snfoundry 18 | uses: foundry-rs/setup-snfoundry@v3 19 | with: 20 | starknet-foundry-version: "0.27.0" 21 | 22 | - name: Run Cairo tests 23 | id: cairo_tests 24 | run: scarb test 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .snfoundry_cache -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | scarb 2.6.5 2 | starknet-foundry 0.27.0 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Carbonable 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

ERC-2981 NFT Royalty Standard

3 |

4 | 5 | 6 | 7 | 8 | 9 | 10 |

11 |

NFT Royalty Contracts written in Cairo for Starknet.

12 |
13 | 14 | ### About 15 | 16 | A Cairo implementation of [EIP-2981](https://eips.ethereum.org/EIPS/eip-2981) based on [Openzeppelin Solidity implementation](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/common/ERC2981.sol). EIP-2981 is an Ethereum standard for NFT Royalty management. 17 | 18 | > ## ⚠️ WARNING! ⚠️ 19 | > 20 | > This is repo contains highly experimental code. 21 | > Expect rapid iteration. 22 | > **Use at your own risk.** 23 | 24 | ### Project setup 25 | 26 | #### 📦 Requirements 27 | 28 | - [scarb](https://docs.swmansion.com/scarb/) 29 | 30 | ### ⛏️ Compile 31 | 32 | ```bash 33 | scarb build 34 | ``` 35 | 36 | ### 💄 Code style 37 | 38 | ```bash 39 | scarb fmt 40 | ``` 41 | 42 | ### 🌡️ Test 43 | 44 | ```bash 45 | scarb test 46 | ``` 47 | 48 | ## 📄 License 49 | 50 | This project is released under the MIT license. 51 | -------------------------------------------------------------------------------- /Scarb.lock: -------------------------------------------------------------------------------- 1 | # Code generated by scarb DO NOT EDIT. 2 | version = 1 3 | 4 | [[package]] 5 | name = "cairo_erc_2981" 6 | version = "2.0.0" 7 | dependencies = [ 8 | "openzeppelin", 9 | "snforge_std", 10 | ] 11 | 12 | [[package]] 13 | name = "openzeppelin" 14 | version = "0.14.0" 15 | source = "git+https://github.com/OpenZeppelin/cairo-contracts.git?tag=v0.14.0#f091c4f51ddeb10297db984acae965328c5a4e5b" 16 | 17 | [[package]] 18 | name = "snforge_std" 19 | version = "0.27.0" 20 | source = "git+https://github.com/foundry-rs/starknet-foundry.git?tag=v0.27.0#2d99b7c00678ef0363881ee0273550c44a9263de" 21 | -------------------------------------------------------------------------------- /Scarb.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cairo_erc_2981" 3 | version = "2.0.0" 4 | 5 | [lib] 6 | 7 | [dependencies] 8 | starknet = "2.6.4" 9 | openzeppelin = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v0.14.0" } 10 | 11 | [dev-dependencies] 12 | snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry.git", tag = "v0.27.0" } 13 | 14 | [scripts] 15 | test = "snforge test" 16 | 17 | [[target.starknet-contract]] 18 | sierra = true 19 | casm = true -------------------------------------------------------------------------------- /src/components/erc2981.cairo: -------------------------------------------------------------------------------- 1 | //! Component implementing IERC2981. 2 | #[starknet::component] 3 | mod ERC2981Component { 4 | // Starknet deps 5 | use starknet::{ContractAddress}; 6 | 7 | // OZ deps 8 | use openzeppelin::{ 9 | introspection::{ 10 | src5::{ 11 | SRC5Component, SRC5Component::InternalTrait as SRC5InternalTrait, 12 | SRC5Component::SRC5Impl 13 | }, 14 | interface::{ISRC5Dispatcher, ISRC5DispatcherTrait} 15 | } 16 | }; 17 | 18 | // Local deps 19 | use cairo_erc_2981::interfaces::erc2981::{IERC2981, IERC2981Camel, IERC2981_ID}; 20 | 21 | #[storage] 22 | struct Storage { 23 | ERC2981_receiver: ContractAddress, 24 | ERC2981_token_receiver: LegacyMap, 25 | ERC2981_fee_numerator: u256, 26 | ERC2981_token_fee_numerator: LegacyMap, 27 | ERC2981_fee_denominator: u256, 28 | ERC2981_token_fee_denominator: LegacyMap, 29 | } 30 | 31 | #[embeddable_as(ERC2981Impl)] 32 | impl ERC2981< 33 | TContractState, 34 | +HasComponent, 35 | +SRC5Component::HasComponent, 36 | +Drop, 37 | > of IERC2981> { 38 | /// Return the default royalty. 39 | /// 40 | /// # Returns 41 | /// 42 | /// * `receiver` - The royalty receiver address. 43 | /// * `fee_numerator` - The royalty rate numerator. 44 | /// * `fee_denominator` - The royalty rate denominator. 45 | fn default_royalty(self: @ComponentState) -> (ContractAddress, u256, u256) { 46 | ( 47 | self.ERC2981_receiver.read(), 48 | self.ERC2981_fee_numerator.read(), 49 | self.ERC2981_fee_denominator.read() 50 | ) 51 | } 52 | 53 | /// Return the token royalty. 54 | /// 55 | /// # Arguments 56 | /// 57 | /// * `token_id` - The token identifier. 58 | /// 59 | /// # Returns 60 | /// 61 | /// * `receiver` - The royalty receiver address. 62 | /// * `fee_numerator` - The royalty rate numerator. 63 | /// * `fee_denominator` - The royalty rate denominator. 64 | fn token_royalty( 65 | self: @ComponentState, token_id: u256 66 | ) -> (ContractAddress, u256, u256) { 67 | ( 68 | self.ERC2981_token_receiver.read(token_id), 69 | self.ERC2981_token_fee_numerator.read(token_id), 70 | self.ERC2981_token_fee_denominator.read(token_id) 71 | ) 72 | } 73 | 74 | /// Return the royalty info with the specified token id and the sale price. 75 | /// 76 | /// Since royalty rate is lower than or equal to 1, royalty_amount is lower than or 77 | /// equal to sale_price, therefore result matches u256. 78 | /// 79 | /// # Arguments 80 | /// 81 | /// * `token_id` - The token identifier. 82 | /// * `sale_price` - The transaction price. 83 | /// 84 | /// # Returns 85 | /// 86 | /// * `receiver` - The royalty receiver address. 87 | /// * `royalty_amount` - The royalty amount. 88 | fn royalty_info( 89 | self: @ComponentState, token_id: u256, sale_price: u256 90 | ) -> (ContractAddress, u256) { 91 | let receiver = self.ERC2981_token_receiver.read(token_id); 92 | if !receiver.is_zero() { 93 | return self._token_royalty_info(token_id, sale_price); 94 | } 95 | self._default_royalty_info(sale_price) 96 | } 97 | 98 | /// Set the default royalty rate. 99 | /// 100 | /// Since float number can not be handled, the rate is managed by a numerator and a 101 | /// denominator. 102 | /// It fails if receiver is the null address. 103 | /// It fails if fee_denominator == 0 or fee_numerator > fee_denominator. 104 | /// 105 | /// # Arguments 106 | /// 107 | /// * `receiver` - The royalty receiver address. 108 | /// * `fee_numerator` - The royalty rate numerator. 109 | /// * `fee_denominator` - The royalty rate denominator. 110 | fn set_default_royalty( 111 | ref self: ComponentState, 112 | receiver: ContractAddress, 113 | fee_numerator: u256, 114 | fee_denominator: u256 115 | ) { 116 | // [Check] Receiver is not zero 117 | assert(!receiver.is_zero(), 'Invalid receiver'); 118 | 119 | // [Check] Fee denominator is not zero 120 | assert(fee_denominator != 0, 'Invalid fee denominator'); 121 | 122 | // [Check] Fee is lower or equal to 1 123 | assert(fee_numerator <= fee_denominator, 'Invalid fee rate'); 124 | 125 | // [Assert] Caller is owner 126 | // let mut ownable_comp = get_dep_component!(@self, Owner); 127 | // ownable_comp.assert_only_owner(); 128 | 129 | // [Effect] Store values 130 | self.ERC2981_receiver.write(receiver); 131 | self.ERC2981_fee_numerator.write(fee_numerator); 132 | self.ERC2981_fee_denominator.write(fee_denominator); 133 | } 134 | 135 | /// Set the token royalty rate. 136 | /// 137 | /// Since float number can not be handled, the rate is managed by a numerator and a 138 | /// denominator. 139 | /// It fails if receiver is the null address. 140 | /// It fails if fee_denominator == 0 or fee_numerator > fee_denominator. 141 | /// 142 | /// # Arguments 143 | /// 144 | /// * `token_id` - The token identifier. 145 | /// * `receiver` - The royalty receiver address. 146 | /// * `fee_numerator` - The royalty rate numerator. 147 | /// * `fee_denominator` - The royalty rate denominator. 148 | fn set_token_royalty( 149 | ref self: ComponentState, 150 | token_id: u256, 151 | receiver: ContractAddress, 152 | fee_numerator: u256, 153 | fee_denominator: u256 154 | ) { 155 | // [Check] Receiver is not zero 156 | assert(!receiver.is_zero(), 'Invalid receiver'); 157 | 158 | // [Check] Fee denominator is not zero 159 | assert(fee_denominator != 0, 'Invalid fee denominator'); 160 | 161 | // [Check] Fee is lower or equal to 1 162 | assert(fee_numerator <= fee_denominator, 'Invalid fee rate'); 163 | 164 | // [Assert] Caller is owner 165 | // let mut ownable_comp = get_dep_component!(@self, Owner); 166 | // ownable_comp.assert_only_owner(); 167 | 168 | // [Effect] Store values 169 | self.ERC2981_token_receiver.write(token_id, receiver); 170 | self.ERC2981_token_fee_numerator.write(token_id, fee_numerator); 171 | self.ERC2981_token_fee_denominator.write(token_id, fee_denominator); 172 | } 173 | } 174 | 175 | #[embeddable_as(ERC2981CamelImpl)] 176 | impl ERC2981CamelOnly< 177 | TContractState, 178 | +HasComponent, 179 | +SRC5Component::HasComponent, 180 | +Drop, 181 | > of IERC2981Camel> { 182 | fn defaultRoyalty(self: @ComponentState) -> (ContractAddress, u256, u256) { 183 | self.default_royalty() 184 | } 185 | 186 | fn tokenRoyalty( 187 | self: @ComponentState, tokenId: u256 188 | ) -> (ContractAddress, u256, u256) { 189 | self.token_royalty(tokenId) 190 | } 191 | 192 | fn royaltyInfo( 193 | self: @ComponentState, tokenId: u256, salePrice: u256 194 | ) -> (ContractAddress, u256) { 195 | self.royalty_info(tokenId, salePrice) 196 | } 197 | 198 | fn setDefaultRoyalty( 199 | ref self: ComponentState, 200 | receiver: ContractAddress, 201 | feeNumerator: u256, 202 | feeDenominator: u256 203 | ) { 204 | self.set_default_royalty(receiver, feeNumerator, feeDenominator) 205 | } 206 | 207 | fn setTokenRoyalty( 208 | ref self: ComponentState, 209 | tokenId: u256, 210 | receiver: ContractAddress, 211 | feeNumerator: u256, 212 | feeDenominator: u256 213 | ) { 214 | self.set_token_royalty(tokenId, receiver, feeNumerator, feeDenominator) 215 | } 216 | } 217 | 218 | #[generate_trait] 219 | pub impl InternalImpl< 220 | TContractState, 221 | +HasComponent, 222 | impl SRC5: SRC5Component::HasComponent, 223 | +Drop 224 | > of InternalTrait { 225 | /// Initialize the component. 226 | /// 227 | /// # Arguments 228 | /// 229 | /// * `receiver` - The royalty receiver address. 230 | /// * `fee_numerator` - The royalty rate numerator.+ 231 | /// * `fee_denominator` - The royalty rate denominator. 232 | fn initializer( 233 | ref self: ComponentState, 234 | receiver: ContractAddress, 235 | fee_numerator: u256, 236 | fee_denominator: u256, 237 | ) { 238 | // [Effect] Register interfaces 239 | let mut src5_component = get_dep_component_mut!(ref self, SRC5); 240 | src5_component.register_interface(IERC2981_ID); 241 | 242 | // [Effect] Update default royalty 243 | self.set_default_royalty(receiver, fee_numerator, fee_denominator); 244 | } 245 | 246 | /// Return default royalty info according to the provided sale price. 247 | /// 248 | /// # Arguments 249 | /// 250 | /// * `sale_price` - The transaction price. 251 | /// 252 | /// # Return 253 | /// 254 | /// * `receiver` - The royalty receiver address. 255 | /// * `royalty_amount` - The royalty amount. 256 | fn _default_royalty_info( 257 | self: @ComponentState, sale_price: u256 258 | ) -> (ContractAddress, u256) { 259 | let (receiver, fee_numerator, fee_denominator) = self.default_royalty(); 260 | (receiver, sale_price * fee_numerator / fee_denominator) 261 | } 262 | 263 | /// Return token royalty info according to the provided sale price. 264 | /// 265 | /// # Arguments 266 | /// 267 | /// * `token_id` - The token identifier. 268 | /// * `sale_price` - The transaction price. 269 | /// 270 | /// # Return 271 | /// 272 | /// * `receiver` - The royalty receiver address. 273 | /// * `royalty_amount` - The royalty amount. 274 | fn _token_royalty_info( 275 | self: @ComponentState, token_id: u256, sale_price: u256 276 | ) -> (ContractAddress, u256) { 277 | let (receiver, fee_numerator, fee_denominator) = self.token_royalty(token_id); 278 | (receiver, sale_price * fee_numerator / fee_denominator) 279 | } 280 | } 281 | } 282 | 283 | #[cfg(test)] 284 | mod Test { 285 | // starknet deps 286 | use starknet::ContractAddress; 287 | use cairo_erc_2981::components::erc2981::ERC2981Component::HasComponent; 288 | use cairo_erc_2981::interfaces::erc2981::{IERC2981}; 289 | use cairo_erc_2981::components::erc2981::ERC2981Component::InternalTrait; 290 | 291 | // Local deps 292 | use super::ERC2981Component; 293 | use cairo_erc_2981::mocks::erc2981::MockERC2981; 294 | 295 | // Constants 296 | const FEE_NUMERATOR: u256 = 1; 297 | const FEE_DENOMINATOR: u256 = 100; 298 | const NEW_FEE_NUMERATOR: u256 = 2; 299 | const NEW_FEE_DENOMINATOR: u256 = 101; 300 | const TOKEN_ID: u256 = 1; 301 | const SALE_PRICE: u256 = 1000000; 302 | 303 | type ERC2981ComponentState = ERC2981Component::ComponentState; 304 | 305 | fn STATE() -> ERC2981ComponentState { 306 | ERC2981Component::component_state_for_testing() 307 | } 308 | 309 | fn ZERO() -> starknet::ContractAddress { 310 | starknet::contract_address_const::<0>() 311 | } 312 | 313 | fn RECEIVER() -> starknet::ContractAddress { 314 | starknet::contract_address_const::<'RECEIVER'>() 315 | } 316 | 317 | fn NEW_RECEIVER() -> starknet::ContractAddress { 318 | starknet::contract_address_const::<'NEW_RECEIVER'>() 319 | } 320 | 321 | #[test] 322 | #[available_gas(250_000)] 323 | fn test_initialization() { 324 | // [Setup] 325 | let mut state = STATE(); 326 | 327 | state.initializer(RECEIVER(), FEE_NUMERATOR, FEE_DENOMINATOR); 328 | 329 | // [Assert] Default royalty 330 | let (receiver, fee_numerator, fee_denominator) = state.default_royalty(); 331 | assert(receiver == RECEIVER(), 'Invalid receiver'); 332 | assert(fee_numerator == FEE_NUMERATOR, 'Invalid fee numerator'); 333 | assert(fee_denominator == FEE_DENOMINATOR, 'Invalid fee denominator'); 334 | } 335 | 336 | 337 | #[test] 338 | #[available_gas(105_000)] 339 | #[should_panic(expected: ('Invalid receiver',))] 340 | fn test_initialization_revert_invalid_receiver() { 341 | // [Setup] 342 | let mut state = STATE(); 343 | // [Revert] Initialization 344 | state.initializer(ZERO(), FEE_NUMERATOR, FEE_DENOMINATOR); 345 | } 346 | 347 | #[test] 348 | #[available_gas(105_000)] 349 | #[should_panic(expected: ('Invalid fee denominator',))] 350 | fn test_initialization_revert_invalid_fee_denominator() { 351 | // [Setup] 352 | let mut state = STATE(); 353 | // [Revert] Initialization 354 | state.initializer(RECEIVER(), FEE_NUMERATOR, 0); 355 | } 356 | 357 | #[test] 358 | #[available_gas(105_000)] 359 | #[should_panic(expected: ('Invalid fee rate',))] 360 | fn test_initialization_revert_invalid_fee_rate() { 361 | // [Setup] 362 | let mut state = STATE(); 363 | // [Revert] Initialization 364 | state.initializer(RECEIVER(), FEE_DENOMINATOR, FEE_NUMERATOR); 365 | } 366 | 367 | #[test] 368 | #[available_gas(380_000)] 369 | fn test_default_royalty() { 370 | // [Setup] 371 | let mut state = STATE(); 372 | state.initializer(RECEIVER(), FEE_NUMERATOR, FEE_DENOMINATOR); 373 | // [Assert] Default royalty info 374 | let (receiver, royalty_amount) = state.royalty_info(TOKEN_ID, SALE_PRICE); 375 | assert(receiver == RECEIVER(), 'Invalid receiver'); 376 | assert( 377 | royalty_amount == SALE_PRICE * FEE_NUMERATOR / FEE_DENOMINATOR, 378 | 'Invalid royalty 379 | amount' 380 | ); 381 | } 382 | 383 | #[test] 384 | #[available_gas(480_000)] 385 | fn test_set_default_royalty() { 386 | // [Setup] 387 | let mut state = STATE(); 388 | state.initializer(RECEIVER(), FEE_NUMERATOR, FEE_DENOMINATOR); 389 | // [Effect] Set default royalty 390 | state.set_default_royalty(NEW_RECEIVER(), NEW_FEE_NUMERATOR, NEW_FEE_DENOMINATOR); 391 | // [Assert] Default royalty info 392 | let (receiver, royalty_amount) = state.royalty_info(TOKEN_ID, SALE_PRICE); 393 | assert(receiver == NEW_RECEIVER(), 'Invalid receiver'); 394 | assert( 395 | royalty_amount == SALE_PRICE * NEW_FEE_NUMERATOR / NEW_FEE_DENOMINATOR, 396 | 'Invalid royalty amount' 397 | ); 398 | } 399 | 400 | #[test] 401 | #[available_gas(760_000)] 402 | fn test_set_token_royalty() { 403 | // [Setup] 404 | let mut state = STATE(); 405 | state.initializer(RECEIVER(), FEE_NUMERATOR, FEE_DENOMINATOR); 406 | // [Effect] Set token royalty 407 | state.set_token_royalty(TOKEN_ID, NEW_RECEIVER(), NEW_FEE_NUMERATOR, NEW_FEE_DENOMINATOR); 408 | 409 | // [Assert] Token royalty info 410 | let (receiver, royalty_amount) = state.royalty_info(TOKEN_ID, SALE_PRICE); 411 | assert(receiver == NEW_RECEIVER(), 'Invalid receiver'); 412 | assert( 413 | royalty_amount == SALE_PRICE * NEW_FEE_NUMERATOR / NEW_FEE_DENOMINATOR, 414 | 'Invalid royalty amount' 415 | ); 416 | 417 | // [Assert] Default royalty info 418 | let (receiver, royalty_amount) = state.royalty_info(0, SALE_PRICE); 419 | assert(receiver == RECEIVER(), 'Invalid receiver'); 420 | assert( 421 | royalty_amount == SALE_PRICE * FEE_NUMERATOR / FEE_DENOMINATOR, 422 | 'Invalid royalty 423 | amount' 424 | ); 425 | } 426 | 427 | #[test] 428 | #[available_gas(250_000)] 429 | #[should_panic(expected: ('Invalid receiver',))] 430 | fn test_set_token_royalty_revert_invalid_receiver() { 431 | // [Setup] 432 | let mut state = STATE(); 433 | state.initializer(RECEIVER(), FEE_NUMERATOR, FEE_DENOMINATOR); 434 | // [Revert] Set token royalty 435 | state.set_token_royalty(TOKEN_ID, ZERO(), NEW_FEE_NUMERATOR, NEW_FEE_DENOMINATOR); 436 | } 437 | 438 | #[test] 439 | #[available_gas(250_000)] 440 | #[should_panic(expected: ('Invalid fee denominator',))] 441 | fn test_set_token_royalty_revert_invalid_fee_denominator() { 442 | // [Setup] 443 | let mut state = STATE(); 444 | state.initializer(RECEIVER(), FEE_NUMERATOR, FEE_DENOMINATOR); 445 | // [Revert] Set token royalty 446 | state.set_token_royalty(TOKEN_ID, NEW_RECEIVER(), NEW_FEE_NUMERATOR, 0); 447 | } 448 | 449 | #[test] 450 | #[available_gas(250_000)] 451 | #[should_panic(expected: ('Invalid fee rate',))] 452 | fn test_set_token_royalty_revert_invalid_fee_rate() { 453 | // [Setup] 454 | let mut state = STATE(); 455 | state.initializer(RECEIVER(), FEE_NUMERATOR, FEE_DENOMINATOR); 456 | // [Revert] Set token royalty 457 | state.set_token_royalty(TOKEN_ID, NEW_RECEIVER(), NEW_FEE_DENOMINATOR, NEW_FEE_NUMERATOR); 458 | } 459 | } 460 | 461 | -------------------------------------------------------------------------------- /src/interfaces/erc2981.cairo: -------------------------------------------------------------------------------- 1 | use starknet::ContractAddress; 2 | 3 | const IERC2981_ID: felt252 = 0x2a55205a; 4 | 5 | #[starknet::interface] 6 | trait IERC2981 { 7 | fn default_royalty(self: @TContractState) -> (ContractAddress, u256, u256); 8 | fn token_royalty(self: @TContractState, token_id: u256) -> (ContractAddress, u256, u256); 9 | fn royalty_info( 10 | self: @TContractState, token_id: u256, sale_price: u256 11 | ) -> (ContractAddress, u256); 12 | fn set_default_royalty( 13 | ref self: TContractState, 14 | receiver: ContractAddress, 15 | fee_numerator: u256, 16 | fee_denominator: u256 17 | ); 18 | fn set_token_royalty( 19 | ref self: TContractState, 20 | token_id: u256, 21 | receiver: ContractAddress, 22 | fee_numerator: u256, 23 | fee_denominator: u256 24 | ); 25 | } 26 | 27 | #[starknet::interface] 28 | trait IERC2981Camel { 29 | fn defaultRoyalty(self: @TContractState) -> (ContractAddress, u256, u256); 30 | fn tokenRoyalty(self: @TContractState, tokenId: u256) -> (ContractAddress, u256, u256); 31 | fn royaltyInfo( 32 | self: @TContractState, tokenId: u256, salePrice: u256 33 | ) -> (ContractAddress, u256); 34 | fn setDefaultRoyalty( 35 | ref self: TContractState, 36 | receiver: ContractAddress, 37 | feeNumerator: u256, 38 | feeDenominator: u256 39 | ); 40 | fn setTokenRoyalty( 41 | ref self: TContractState, 42 | tokenId: u256, 43 | receiver: ContractAddress, 44 | feeNumerator: u256, 45 | feeDenominator: u256 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/lib.cairo: -------------------------------------------------------------------------------- 1 | mod components { 2 | mod erc2981; 3 | } 4 | 5 | mod interfaces { 6 | mod erc2981; 7 | } 8 | 9 | mod presets { 10 | mod erc721_royalty; 11 | } 12 | 13 | mod mocks { 14 | mod erc2981; 15 | } 16 | 17 | #[cfg(test)] 18 | mod tests { 19 | mod test_erc721_royalty; 20 | } 21 | -------------------------------------------------------------------------------- /src/mocks/erc2981.cairo: -------------------------------------------------------------------------------- 1 | #[starknet::contract] 2 | mod MockERC2981 { 3 | /// OZ deps 4 | use openzeppelin::{access::ownable::OwnableComponent, introspection::src5::SRC5Component,}; 5 | 6 | // local deps 7 | use cairo_erc_2981::components::erc2981::ERC2981Component; 8 | 9 | component!(path: ERC2981Component, storage: erc2981, event: ERC2981Event); 10 | component!(path: SRC5Component, storage: src5, event: SRC5Event); 11 | component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); 12 | 13 | #[abi(embed_v0)] 14 | impl ERC2981Impl = ERC2981Component::ERC2981Impl; 15 | impl ERC2981InternalImpl = ERC2981Component::InternalImpl; 16 | 17 | // Ownable Mixin 18 | #[abi(embed_v0)] 19 | impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl; 20 | impl OwnableInternalImpl = OwnableComponent::InternalImpl; 21 | 22 | #[storage] 23 | struct Storage { 24 | #[substorage(v0)] 25 | erc2981: ERC2981Component::Storage, 26 | #[substorage(v0)] 27 | src5: SRC5Component::Storage, 28 | #[substorage(v0)] 29 | ownable: OwnableComponent::Storage, 30 | } 31 | 32 | #[event] 33 | #[derive(Drop, starknet::Event)] 34 | enum Event { 35 | #[flat] 36 | ERC2981Event: ERC2981Component::Event, 37 | #[flat] 38 | SRC5Event: SRC5Component::Event, 39 | #[flat] 40 | OwnableEvent: OwnableComponent::Event, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/presets/erc721_royalty.cairo: -------------------------------------------------------------------------------- 1 | #[starknet::contract] 2 | mod ERC721Royalty { 3 | // Starknet deps 4 | use starknet::{get_caller_address, ContractAddress}; 5 | 6 | // OZ deps 7 | use openzeppelin::{ 8 | access::ownable::{ 9 | OwnableComponent, OwnableComponent::{InternalTrait as OwnableInternalTrait} 10 | }, 11 | introspection::src5::SRC5Component, token::erc721::{ERC721Component, ERC721HooksEmptyImpl} 12 | }; 13 | 14 | // Local deps 15 | use cairo_erc_2981::components::erc2981::ERC2981Component; 16 | use cairo_erc_2981::interfaces::erc2981::{IERC2981, IERC2981Camel}; 17 | 18 | component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); 19 | component!(path: ERC721Component, storage: erc721, event: ERC721Event); 20 | component!(path: SRC5Component, storage: src5, event: SRC5Event); 21 | component!(path: ERC2981Component, storage: erc2981, event: ERC2981Event); 22 | 23 | 24 | // Ownable Mixin 25 | #[abi(embed_v0)] 26 | impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl; 27 | impl OwnableInternalImpl = OwnableComponent::InternalImpl; 28 | 29 | // ERC721 Mixin 30 | #[abi(embed_v0)] 31 | impl ERC721MixinImpl = ERC721Component::ERC721MixinImpl; 32 | impl ERC721InternalImpl = ERC721Component::InternalImpl; 33 | 34 | // ERC2981 35 | impl ERC2981Impl = ERC2981Component::ERC2981Impl; 36 | impl ERC2981InternalImpl = ERC2981Component::InternalImpl; 37 | 38 | #[storage] 39 | struct Storage { 40 | #[substorage(v0)] 41 | ownable: OwnableComponent::Storage, 42 | #[substorage(v0)] 43 | erc721: ERC721Component::Storage, 44 | #[substorage(v0)] 45 | src5: SRC5Component::Storage, 46 | #[substorage(v0)] 47 | erc2981: ERC2981Component::Storage 48 | } 49 | 50 | #[event] 51 | #[derive(Drop, starknet::Event)] 52 | enum Event { 53 | #[flat] 54 | OwnableEvent: OwnableComponent::Event, 55 | #[flat] 56 | ERC721Event: ERC721Component::Event, 57 | #[flat] 58 | SRC5Event: SRC5Component::Event, 59 | #[flat] 60 | ERC2981Event: ERC2981Component::Event 61 | } 62 | 63 | #[constructor] 64 | fn constructor( 65 | ref self: ContractState, 66 | name: ByteArray, 67 | symbol: ByteArray, 68 | base_uri: ByteArray, 69 | receiver: ContractAddress, 70 | fee_numerator: u256, 71 | fee_denominator: u256, 72 | owner: ContractAddress 73 | ) { 74 | self.initializer(name, symbol, base_uri, receiver, fee_numerator, fee_denominator, owner); 75 | } 76 | 77 | #[abi(embed_v0)] 78 | impl ERC721RoyaltyImpl of IERC2981 { 79 | fn default_royalty(self: @ContractState) -> (ContractAddress, u256, u256) { 80 | self.erc2981.default_royalty() 81 | } 82 | 83 | fn token_royalty(self: @ContractState, token_id: u256) -> (ContractAddress, u256, u256) { 84 | self.erc2981.token_royalty(token_id) 85 | } 86 | 87 | fn royalty_info( 88 | self: @ContractState, token_id: u256, sale_price: u256 89 | ) -> (ContractAddress, u256) { 90 | self.erc2981.royalty_info(token_id, sale_price) 91 | } 92 | 93 | fn set_default_royalty( 94 | ref self: ContractState, 95 | receiver: ContractAddress, 96 | fee_numerator: u256, 97 | fee_denominator: u256 98 | ) { 99 | self.ownable.assert_only_owner(); 100 | self.erc2981.set_default_royalty(receiver, fee_numerator, fee_denominator); 101 | } 102 | 103 | fn set_token_royalty( 104 | ref self: ContractState, 105 | token_id: u256, 106 | receiver: ContractAddress, 107 | fee_numerator: u256, 108 | fee_denominator: u256 109 | ) { 110 | self.ownable.assert_only_owner(); 111 | self.erc2981.set_token_royalty(token_id, receiver, fee_numerator, fee_denominator); 112 | } 113 | } 114 | 115 | #[generate_trait] 116 | impl InternalImpl of InternalTrait { 117 | fn initializer( 118 | ref self: ContractState, 119 | name: ByteArray, 120 | symbol: ByteArray, 121 | base_uri: ByteArray, 122 | receiver: ContractAddress, 123 | fee_numerator: u256, 124 | fee_denominator: u256, 125 | owner: ContractAddress 126 | ) { 127 | // ERC721 128 | self.erc721.initializer(name, symbol, base_uri); 129 | 130 | // ERC2981 131 | self.erc2981.initializer(receiver, fee_numerator, fee_denominator); 132 | 133 | // Access control 134 | self.ownable.initializer(owner); 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/tests/test_erc721_royalty.cairo: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod Test { 3 | // Core deps 4 | use core::serde::Serde; 5 | 6 | // Starknet-Foundry deps 7 | use snforge_std::{ 8 | declare, ContractClassTrait, start_cheat_caller_address, stop_cheat_caller_address 9 | }; 10 | 11 | // Starknet deps 12 | use starknet::{ContractAddress, deploy_syscall}; 13 | 14 | // Dispatchers 15 | use cairo_erc_2981::interfaces::erc2981::{IERC2981Dispatcher, IERC2981DispatcherTrait}; 16 | 17 | // Contracts 18 | use cairo_erc_2981::presets::erc721_royalty::ERC721Royalty; 19 | 20 | // Constants 21 | const RECEIVER: felt252 = 'RECEIVER'; 22 | const NEW_RECEIVER: felt252 = 'NEW_RECEIVER'; 23 | const OWNER: felt252 = 'OWNER'; 24 | const TOKEN_ID: u256 = 1; 25 | const FEE_NUMERATOR: u256 = 5; 26 | const FEE_DENOMINATOR: u256 = 100; 27 | const NEW_FEE_NUMERATOR: u256 = 10; 28 | const NEW_FEE_DENOMINATOR: u256 = 50; 29 | 30 | // Setup 31 | fn setup(receiver: ContractAddress, owner: ContractAddress) -> ContractAddress { 32 | let name: ByteArray = "NAME"; 33 | let symbol: ByteArray = "SYMBOL"; 34 | let base_uri: ByteArray = "ipfs://abcdefghi/"; 35 | 36 | let mut calldata: Array = array![]; 37 | name.serialize(ref calldata); 38 | symbol.serialize(ref calldata); 39 | base_uri.serialize(ref calldata); 40 | receiver.serialize(ref calldata); 41 | FEE_NUMERATOR.low.serialize(ref calldata); 42 | FEE_NUMERATOR.high.serialize(ref calldata); 43 | FEE_DENOMINATOR.low.serialize(ref calldata); 44 | FEE_DENOMINATOR.high.serialize(ref calldata); 45 | owner.serialize(ref calldata); 46 | 47 | let contract = declare("ERC721Royalty").unwrap(); 48 | let (contract_address, _) = contract.deploy(@calldata).unwrap(); 49 | 50 | contract_address 51 | } 52 | 53 | // Tests 54 | #[test] 55 | #[available_gas(1_250_000)] 56 | fn test_initialization() { 57 | // [Setup] 58 | let preset_contract_address = setup( 59 | RECEIVER.try_into().unwrap(), OWNER.try_into().unwrap() 60 | ); 61 | let preset = IERC2981Dispatcher { contract_address: preset_contract_address }; 62 | 63 | // [Assert] Provide minter rights to anyone 64 | let (receiver, fee_numerator, fee_denominator) = preset.default_royalty(); 65 | assert(receiver == RECEIVER.try_into().unwrap(), 'Invalid receiver'); 66 | assert(fee_numerator == FEE_NUMERATOR.into(), 'Invalid fee numerator'); 67 | assert(fee_denominator == FEE_DENOMINATOR.into(), 'Invalid fee denominator'); 68 | } 69 | 70 | #[test] 71 | #[available_gas(1_600_000)] 72 | fn test_set_default_royalty() { 73 | // [Setup] 74 | let preset_contract_address = setup( 75 | RECEIVER.try_into().unwrap(), OWNER.try_into().unwrap() 76 | ); 77 | let preset = IERC2981Dispatcher { contract_address: preset_contract_address }; 78 | 79 | // [Effect] Set default royalty 80 | start_cheat_caller_address(preset_contract_address, OWNER.try_into().unwrap()); 81 | preset 82 | .set_default_royalty( 83 | NEW_RECEIVER.try_into().unwrap(), NEW_FEE_NUMERATOR, NEW_FEE_DENOMINATOR 84 | ); 85 | stop_cheat_caller_address(preset_contract_address); 86 | 87 | // [Assert] Default royalty 88 | let (receiver, fee_numerator, fee_denominator) = preset.default_royalty(); 89 | assert(receiver == NEW_RECEIVER.try_into().unwrap(), 'Invalid receiver'); 90 | assert(fee_numerator == NEW_FEE_NUMERATOR.into(), 'Invalid fee numerator'); 91 | assert(fee_denominator == NEW_FEE_DENOMINATOR.into(), 'Invalid fee denominator'); 92 | } 93 | 94 | #[test] 95 | #[available_gas(1_250_000)] 96 | #[should_panic] 97 | fn test_set_default_royalty_revert_not_owner() { 98 | // [Setup] 99 | let preset_contract_address = setup( 100 | RECEIVER.try_into().unwrap(), OWNER.try_into().unwrap() 101 | ); 102 | let preset = IERC2981Dispatcher { contract_address: preset_contract_address }; 103 | 104 | // [Revert] Set default royalty 105 | start_cheat_caller_address(preset_contract_address, NEW_RECEIVER.try_into().unwrap()); 106 | preset 107 | .set_default_royalty( 108 | NEW_RECEIVER.try_into().unwrap(), NEW_FEE_NUMERATOR, NEW_FEE_DENOMINATOR 109 | ); 110 | stop_cheat_caller_address(preset_contract_address); 111 | } 112 | 113 | #[test] 114 | #[available_gas(1_600_000)] 115 | fn test_set_token_royalty() { 116 | // [Setup] 117 | let preset_contract_address = setup( 118 | RECEIVER.try_into().unwrap(), OWNER.try_into().unwrap() 119 | ); 120 | let preset = IERC2981Dispatcher { contract_address: preset_contract_address }; 121 | 122 | // [Effect] Set default royalty 123 | start_cheat_caller_address(preset_contract_address, OWNER.try_into().unwrap()); 124 | preset 125 | .set_token_royalty( 126 | TOKEN_ID, NEW_RECEIVER.try_into().unwrap(), NEW_FEE_NUMERATOR, NEW_FEE_DENOMINATOR 127 | ); 128 | stop_cheat_caller_address(preset_contract_address); 129 | 130 | // [Assert] Token royalty 131 | let (receiver, fee_numerator, fee_denominator) = preset.token_royalty(TOKEN_ID); 132 | assert(receiver == NEW_RECEIVER.try_into().unwrap(), 'Invalid receiver'); 133 | assert(fee_numerator == NEW_FEE_NUMERATOR.into(), 'Invalid fee numerator'); 134 | assert(fee_denominator == NEW_FEE_DENOMINATOR.into(), 'Invalid fee denominator'); 135 | } 136 | 137 | #[test] 138 | #[available_gas(1_250_000)] 139 | #[should_panic] 140 | fn test_set_token_royalty_revert_not_owner() { 141 | // [Setup] 142 | let preset_contract_address = setup( 143 | RECEIVER.try_into().unwrap(), OWNER.try_into().unwrap() 144 | ); 145 | let preset = IERC2981Dispatcher { contract_address: preset_contract_address }; 146 | 147 | // [Revert] Set default royalty 148 | start_cheat_caller_address(preset_contract_address, NEW_RECEIVER.try_into().unwrap()); 149 | preset 150 | .set_token_royalty( 151 | TOKEN_ID, NEW_RECEIVER.try_into().unwrap(), NEW_FEE_NUMERATOR, NEW_FEE_DENOMINATOR 152 | ); 153 | stop_cheat_caller_address(preset_contract_address); 154 | } 155 | } 156 | --------------------------------------------------------------------------------