├── .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 |
--------------------------------------------------------------------------------