From dab4470f150a9d60ffac00edb0fcec75c09599fe Mon Sep 17 00:00:00 2001 From: ptisserand Date: Mon, 29 Apr 2024 09:10:23 +0200 Subject: [PATCH] feat(starknet): ERC721 uri, bridge ownership and collection white list (#201) * feat(starknet): add `set_base_uri` and `set_token_uri` in ERC721Bridgeable contract * feat(starknet): use OZ two steps Ownable implementation for bridge contract * feat(starknet): add function to retrieve white list * feat(starknet): emit event when collection whitelist list is updated --- apps/blockchain/starknet/src/bridge.cairo | 117 +++++++- apps/blockchain/starknet/src/interfaces.cairo | 10 +- .../starknet/src/tests/bridge_t.cairo | 270 +++++++++++++++++- .../src/token/erc721_bridgeable.cairo | 112 +++++++- .../starknet/src/token/interfaces.cairo | 7 + 5 files changed, 501 insertions(+), 15 deletions(-) diff --git a/apps/blockchain/starknet/src/bridge.cairo b/apps/blockchain/starknet/src/bridge.cairo index a578d4e8..23cbf8af 100644 --- a/apps/blockchain/starknet/src/bridge.cairo +++ b/apps/blockchain/starknet/src/bridge.cairo @@ -14,6 +14,7 @@ mod bridge { use starknet::contract_address::ContractAddressZeroable; use starknet::eth_address::EthAddressZeroable; + use openzeppelin::access::ownable::OwnableComponent; use openzeppelin::access::ownable::interface::{ IOwnableDispatcher, IOwnableDispatcherTrait }; @@ -28,6 +29,7 @@ mod bridge { CollectionDeployedFromL1, ReplacedClassHash, BridgeEnabled, + CollectionWhiteListUpdated, }; use starklane::request::{ @@ -49,10 +51,15 @@ mod bridge { use poseidon::poseidon_hash_span; + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); + + #[abi(embed_v0)] + impl OwnableTwoStepMixinImpl = OwnableComponent::OwnableTwoStepMixinImpl; + + impl OwnableInternalImpl = OwnableComponent::InternalImpl; + #[storage] struct Storage { - // Bridge administrator. - bridge_admin: ContractAddress, // Bridge address on L1 (to allow it to consume messages). bridge_l1_address: EthAddress, // The class to deploy for ERC721 tokens. @@ -67,11 +74,16 @@ mod bridge { // White list enabled flag white_list_enabled: bool, + // Registry of whitelisted collections - white_list: LegacyMap::, - + white_listed_list: LegacyMap::, + white_listed_head: ContractAddress, + // Bridge enabled flag enabled: bool, + + #[substorage(v0)] + ownable: OwnableComponent::Storage, } #[constructor] @@ -81,8 +93,7 @@ mod bridge { bridge_l1_address: EthAddress, erc721_bridgeable_class: ClassHash, ) { - self.bridge_admin.write(bridge_admin); - + self.ownable.initializer(bridge_admin); // TODO: add validation of inputs. self.bridge_l1_address.write(bridge_l1_address); self.erc721_bridgeable_class.write(erc721_bridgeable_class); @@ -98,6 +109,9 @@ mod bridge { WithdrawRequestCompleted: WithdrawRequestCompleted, ReplacedClassHash: ReplacedClassHash, BridgeEnabled: BridgeEnabled, + CollectionWhiteListUpdated: CollectionWhiteListUpdated, + #[flat] + OwnableEvent: OwnableComponent::Event, } @@ -302,13 +316,35 @@ mod bridge { fn white_list_collection(ref self: ContractState, collection: ContractAddress, enabled: bool) { ensure_is_admin(@self); - self.white_list.write(collection, enabled); + _white_list_collection(ref self, collection, enabled); + self.emit(CollectionWhiteListUpdated { + collection, + enabled, + }); } fn is_white_listed(self: @ContractState, collection: ContractAddress) -> bool { _is_white_listed(self, collection) } + fn get_white_listed_collections(self: @ContractState) -> Span { + let mut white_listed = array![]; + let mut current = self.white_listed_head.read(); + loop { + if current.is_zero() { + break; + } + let (enabled, next) = self.white_listed_list.read(current); + if !enabled { + break; + } else { + white_listed.append(current); + current = next; + } + }; + white_listed.span() + } + fn enable(ref self: ContractState, enable: bool) { ensure_is_admin(@self); self.enabled.write(enable); @@ -348,7 +384,7 @@ mod bridge { /// Ensures the caller is the bridge admin. Revert if it's not. fn ensure_is_admin(self: @ContractState) { - assert(starknet::get_caller_address() == self.bridge_admin.read(), 'Unauthorized call'); + self.ownable.assert_only_owner(); } /// Ensures the bridge is enabled @@ -432,8 +468,13 @@ mod bridge { ); // update whitelist if needed - if self.white_list.read(l2_addr_from_deploy) != true { - self.white_list.write(l2_addr_from_deploy, true); + let (already_white_listed, _) = self.white_listed_list.read(l2_addr_from_deploy); + if already_white_listed != true { + _white_list_collection(ref self, l2_addr_from_deploy, true); + self.emit(CollectionWhiteListUpdated { + collection: l2_addr_from_deploy, + enabled: true, + }); } l2_addr_from_deploy } @@ -441,8 +482,62 @@ mod bridge { fn _is_white_listed(self: @ContractState, collection: ContractAddress) -> bool { let enabled = self.white_list_enabled.read(); if (enabled) { - return self.white_list.read(collection); + let (ret, _) = self.white_listed_list.read(collection); + return ret; } true } + + fn _white_list_collection(ref self: ContractState, collection: ContractAddress, enabled: bool) { + let no_value = starknet::contract_address_const::<0>(); + let (current, _) = self.white_listed_list.read(collection); + if current != enabled { + let mut prev = self.white_listed_head.read(); + if enabled { + self.white_listed_list.write(collection, (enabled, no_value)); + if prev.is_zero() { + self.white_listed_head.write(collection); + return; + } + // find last element + loop { + let (_, next) = self.white_listed_list.read(prev); + if next.is_zero() { + break; + } + let (active, _) = self.white_listed_list.read(next); + if !active { + break; + } + prev = next; + }; + self.white_listed_list.write(prev, (true, collection)); + } else { + // change head + if prev == collection { + let (_, next) = self.white_listed_list.read(prev); + self.white_listed_list.write(collection, (false, no_value)); + self.white_listed_head.write(next); + return; + } + // removed element from linked list + loop { + let (active, next) = self.white_listed_list.read(prev); + if next.is_zero() { + // end of list + break; + } + if !active { + break; + } + if next == collection { + let (_, target) = self.white_listed_list.read(collection); + self.white_listed_list.write(prev, (active, target)); + break; + } + }; + self.white_listed_list.write(collection, (false, no_value)); + } + } + } } diff --git a/apps/blockchain/starknet/src/interfaces.cairo b/apps/blockchain/starknet/src/interfaces.cairo index d5cb9df0..33264712 100644 --- a/apps/blockchain/starknet/src/interfaces.cairo +++ b/apps/blockchain/starknet/src/interfaces.cairo @@ -28,6 +28,7 @@ trait IStarklane { fn is_white_list_enabled(self: @T) -> bool; fn white_list_collection(ref self: T, collection: ContractAddress, enabled: bool); fn is_white_listed(self: @T, collection: ContractAddress) -> bool; + fn get_white_listed_collections(self: @T) -> Span; fn enable(ref self: T, enable: bool); fn is_enabled(self: @T) -> bool; @@ -98,4 +99,11 @@ struct L1L2CollectionMappingUpdated { collection_l1: EthAddress, #[key] collection_l2: ContractAddress -} \ No newline at end of file +} + +#[derive(Drop, starknet::Event)] +struct CollectionWhiteListUpdated { + #[key] + collection: ContractAddress, + enabled: bool, +} \ No newline at end of file diff --git a/apps/blockchain/starknet/src/tests/bridge_t.cairo b/apps/blockchain/starknet/src/tests/bridge_t.cairo index a43db473..c7d1043b 100644 --- a/apps/blockchain/starknet/src/tests/bridge_t.cairo +++ b/apps/blockchain/starknet/src/tests/bridge_t.cairo @@ -513,6 +513,8 @@ mod tests { assert!(erc721.owner_of(1) == OWNER_L2, "Wrong owner after req"); assert!(bridge.is_white_listed(deployed_address), "Collection shall be whitelisted"); + let white_listed = bridge.get_white_listed_collections(); + assert_eq!(*white_listed.at(0), deployed_address, "Collection whitelisted shall be in list"); } #[test] @@ -605,6 +607,230 @@ mod tests { stop_prank(CheatTarget::One(bridge_address)); } + #[test] + fn whitelist_collection_is_empty_by_default() { + let erc721b_contract_class = declare("erc721_bridgeable"); + + let BRIDGE_ADMIN = starknet::contract_address_const::<'starklane'>(); + let BRIDGE_L1 = EthAddress { address: 'starklane_l1' }; + + let bridge_address = deploy_starklane(BRIDGE_ADMIN, BRIDGE_L1, erc721b_contract_class.class_hash); + let bridge = IStarklaneDispatcher { contract_address: bridge_address }; + + start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); + bridge.enable_white_list(true); + stop_prank(CheatTarget::One(bridge_address)); + assert!(bridge.get_white_listed_collections().is_empty(), "White list shall be empty by default"); + } + + #[test] + fn whitelist_collection_is_updated_when_collection_is_added() { + let erc721b_contract_class = declare("erc721_bridgeable"); + + let BRIDGE_ADMIN = starknet::contract_address_const::<'starklane'>(); + let BRIDGE_L1 = EthAddress { address: 'starklane_l1' }; + + let bridge_address = deploy_starklane(BRIDGE_ADMIN, BRIDGE_L1, erc721b_contract_class.class_hash); + let bridge = IStarklaneDispatcher { contract_address: bridge_address }; + + start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); + bridge.enable_white_list(true); + stop_prank(CheatTarget::One(bridge_address)); + + let collection1 = starknet::contract_address_const::<'collection1'>(); + let collection2 = starknet::contract_address_const::<'collection2'>(); + let collection3 = starknet::contract_address_const::<'collection3'>(); + + start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); + bridge.white_list_collection(collection1, true); + stop_prank(CheatTarget::One(bridge_address)); + + let white_listed = bridge.get_white_listed_collections(); + assert_eq!(white_listed.len(), 1, "White list shall contain 1 element"); + assert_eq!(*white_listed.at(0), collection1, "Wrong collection address in white list"); + + start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); + bridge.white_list_collection(collection2, true); + bridge.white_list_collection(collection3, true); + stop_prank(CheatTarget::One(bridge_address)); + + let white_listed = bridge.get_white_listed_collections(); + assert_eq!(white_listed.len(), 3, "White list shall contain 3 elements"); + assert_eq!(*white_listed.at(0), collection1, "Wrong collection address in white list"); + assert_eq!(*white_listed.at(1), collection2, "Wrong collection address in white list"); + assert_eq!(*white_listed.at(2), collection3, "Wrong collection address in white list"); + assert!(bridge.is_white_listed(collection1), "Collection1 should be whitelisted"); + assert!(bridge.is_white_listed(collection2), "Collection1 should be whitelisted"); + assert!(bridge.is_white_listed(collection3), "Collection1 should be whitelisted"); + + start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); + bridge.white_list_collection(collection2, true); + stop_prank(CheatTarget::One(bridge_address)); + + let white_listed = bridge.get_white_listed_collections(); + assert_eq!(white_listed.len(), 3, "White list shall contain 3 elements"); + assert_eq!(*white_listed.at(0), collection1, "Wrong collection address in white list"); + assert_eq!(*white_listed.at(1), collection2, "Wrong collection address in white list"); + assert_eq!(*white_listed.at(2), collection3, "Wrong collection address in white list"); + assert!(bridge.is_white_listed(collection1), "Collection1 should be whitelisted"); + assert!(bridge.is_white_listed(collection2), "Collection1 should be whitelisted"); + assert!(bridge.is_white_listed(collection3), "Collection1 should be whitelisted"); + } + + #[test] + fn whitelist_collection_is_updated_when_collection_is_removed() { + let erc721b_contract_class = declare("erc721_bridgeable"); + + let BRIDGE_ADMIN = starknet::contract_address_const::<'starklane'>(); + let BRIDGE_L1 = EthAddress { address: 'starklane_l1' }; + + let bridge_address = deploy_starklane(BRIDGE_ADMIN, BRIDGE_L1, erc721b_contract_class.class_hash); + let bridge = IStarklaneDispatcher { contract_address: bridge_address }; + + start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); + bridge.enable_white_list(true); + stop_prank(CheatTarget::One(bridge_address)); + + let collection1 = starknet::contract_address_const::<'collection1'>(); + let collection2 = starknet::contract_address_const::<'collection2'>(); + let collection3 = starknet::contract_address_const::<'collection3'>(); + + start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); + bridge.white_list_collection(collection1, true); + bridge.white_list_collection(collection2, true); + bridge.white_list_collection(collection3, true); + stop_prank(CheatTarget::One(bridge_address)); + + let white_listed = bridge.get_white_listed_collections(); + assert_eq!(white_listed.len(), 3, "White list shall contain 3 elements"); + assert_eq!(*white_listed.at(0), collection1, "Wrong collection address in white list"); + assert_eq!(*white_listed.at(1), collection2, "Wrong collection address in white list"); + assert_eq!(*white_listed.at(2), collection3, "Wrong collection address in white list"); + assert!(bridge.is_white_listed(collection1), "Collection1 should be whitelisted"); + assert!(bridge.is_white_listed(collection2), "Collection1 should be whitelisted"); + assert!(bridge.is_white_listed(collection3), "Collection1 should be whitelisted"); + + start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); + bridge.white_list_collection(collection2, false); + stop_prank(CheatTarget::One(bridge_address)); + let white_listed = bridge.get_white_listed_collections(); + assert_eq!(white_listed.len(), 2, "White list shall contain 2 elements"); + assert_eq!(*white_listed.at(0), collection1, "Wrong collection address in white list"); + assert_eq!(*white_listed.at(1), collection3, "Wrong collection address in white list"); + assert!(bridge.is_white_listed(collection1), "Collection1 should be whitelisted"); + assert!(!bridge.is_white_listed(collection2), "Collection1 should not be whitelisted"); + assert!(bridge.is_white_listed(collection3), "Collection1 should be whitelisted"); + + start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); + bridge.white_list_collection(collection1, false); + bridge.white_list_collection(collection3, false); + stop_prank(CheatTarget::One(bridge_address)); + let white_listed = bridge.get_white_listed_collections(); + assert!(white_listed.is_empty(), "White list shall be empty"); + assert!(!bridge.is_white_listed(collection1), "Collection1 should not be whitelisted"); + assert!(!bridge.is_white_listed(collection2), "Collection1 should not be whitelisted"); + assert!(!bridge.is_white_listed(collection3), "Collection1 should not be whitelisted"); + + start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); + bridge.white_list_collection(collection1, true); + bridge.white_list_collection(collection3, true); + stop_prank(CheatTarget::One(bridge_address)); + + let white_listed = bridge.get_white_listed_collections(); + assert_eq!(white_listed.len(), 2, "White list shall contain 2 elements"); + assert_eq!(*white_listed.at(0), collection1, "Wrong collection address in white list"); + assert_eq!(*white_listed.at(1), collection3, "Wrong collection address in white list"); + assert!(bridge.is_white_listed(collection1), "Collection1 should be whitelisted"); + assert!(!bridge.is_white_listed(collection2), "Collection1 should not be whitelisted"); + assert!(bridge.is_white_listed(collection3), "Collection1 should be whitelisted"); + + start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); + bridge.white_list_collection(collection1, false); + bridge.white_list_collection(collection2, true); + stop_prank(CheatTarget::One(bridge_address)); + + let white_listed = bridge.get_white_listed_collections(); + assert_eq!(white_listed.len(), 2, "White list shall contain 2 elements"); + assert_eq!(*white_listed.at(0), collection3, "Wrong collection address in white list"); + assert_eq!(*white_listed.at(1), collection2, "Wrong collection address in white list"); + assert!(!bridge.is_white_listed(collection1), "Collection1 should not be whitelisted"); + assert!(bridge.is_white_listed(collection2), "Collection1 should be whitelisted"); + assert!(bridge.is_white_listed(collection3), "Collection1 should be whitelisted"); + + start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); + bridge.white_list_collection(collection2, false); + bridge.white_list_collection(collection3, false); + bridge.white_list_collection(collection1, false); + bridge.white_list_collection(collection1, false); + stop_prank(CheatTarget::One(bridge_address)); + + let white_listed = bridge.get_white_listed_collections(); + assert!(white_listed.is_empty(), "White list shall be empty"); + assert!(!bridge.is_white_listed(collection1), "Collection1 should not be whitelisted"); + assert!(!bridge.is_white_listed(collection2), "Collection1 should not be whitelisted"); + assert!(!bridge.is_white_listed(collection3), "Collection1 should not be whitelisted"); + } + + #[test] + fn whitelist_collection_update_events() { + let erc721b_contract_class = declare("erc721_bridgeable"); + + let BRIDGE_ADMIN = starknet::contract_address_const::<'starklane'>(); + let BRIDGE_L1 = EthAddress { address: 'starklane_l1' }; + + let bridge_address = deploy_starklane(BRIDGE_ADMIN, BRIDGE_L1, erc721b_contract_class.class_hash); + let bridge = IStarklaneDispatcher { contract_address: bridge_address }; + + let collection1 = starknet::contract_address_const::<'collection1'>(); + let collection2 = starknet::contract_address_const::<'collection2'>(); + let collection3 = starknet::contract_address_const::<'collection3'>(); + start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); + bridge.enable_white_list(true); + stop_prank(CheatTarget::One(bridge_address)); + + let mut spy = spy_events(SpyOn::One(bridge_address)); + start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); + bridge.white_list_collection(collection1, true); + stop_prank(CheatTarget::One(bridge_address)); + spy.assert_emitted(@array![ + ( + bridge_address, + bridge::Event::CollectionWhiteListUpdated( + bridge::CollectionWhiteListUpdated { + collection: collection1, + enabled: true, + } + ) + ) + ]); + + start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); + bridge.white_list_collection(collection2, true); + bridge.white_list_collection(collection1, false); + stop_prank(CheatTarget::One(bridge_address)); + spy.assert_emitted(@array![ + ( + bridge_address, + bridge::Event::CollectionWhiteListUpdated( + bridge::CollectionWhiteListUpdated { + collection: collection2, + enabled: true, + } + ) + ), + ( + bridge_address, + bridge::Event::CollectionWhiteListUpdated( + bridge::CollectionWhiteListUpdated { + collection: collection1, + enabled: false, + } + ) + ) + ]); + + } + #[test] #[should_panic] fn deposit_token_not_enabled() { @@ -709,7 +935,7 @@ mod tests { } #[test] - #[should_panic] + #[should_panic(expected: ('Caller is not the owner',))] fn upgrade_as_not_admin() { let erc721b_contract_class = declare("erc721_bridgeable"); @@ -724,6 +950,46 @@ mod tests { stop_prank(CheatTarget::One(bridge_address)); } + #[test] + fn support_two_step_transfer_ownership() { + let erc721b_contract_class = declare("erc721_bridgeable"); + + let BRIDGE_ADMIN = starknet::contract_address_const::<'starklane'>(); + let BRIDGE_L1 = EthAddress { address: 'starklane_l1' }; + let ALICE = starknet::contract_address_const::<'alice'>(); + let contract_address = deploy_starklane(BRIDGE_ADMIN, BRIDGE_L1, erc721b_contract_class.class_hash); + let ownable = IOwnableTwoStepDispatcher { contract_address}; + + assert_eq!(ownable.owner(), BRIDGE_ADMIN, "bad owner"); + start_prank(CheatTarget::One(contract_address), BRIDGE_ADMIN); + ownable.transfer_ownership(ALICE); + stop_prank(CheatTarget::One(contract_address)); + assert_eq!(ownable.owner(), BRIDGE_ADMIN, "bad owner"); + assert_eq!(ownable.pending_owner(), ALICE, "bad pending owner"); + + start_prank(CheatTarget::One(contract_address), ALICE); + ownable.accept_ownership(); + stop_prank(CheatTarget::One(contract_address)); + assert_eq!(ownable.owner(), ALICE, "bad owner"); + } + + #[test] + #[should_panic(expected: ('Caller is not the owner',))] + fn should_panic_transfer_not_owner() { + let erc721b_contract_class = declare("erc721_bridgeable"); + + let BRIDGE_ADMIN = starknet::contract_address_const::<'starklane'>(); + let BRIDGE_L1 = EthAddress { address: 'starklane_l1' }; + let ALICE = starknet::contract_address_const::<'alice'>(); + let contract_address = deploy_starklane(BRIDGE_ADMIN, BRIDGE_L1, erc721b_contract_class.class_hash); + let ownable = IOwnableTwoStepDispatcher { contract_address}; + + assert_eq!(ownable.owner(), BRIDGE_ADMIN, "bad owner"); + start_prank(CheatTarget::One(contract_address), ALICE); + ownable.transfer_ownership(ALICE); + stop_prank(CheatTarget::One(contract_address)); + } + #[test] fn collection_upgrade_as_admin() { let BRIDGE_L1 = EthAddress { address: 'starklane_l1' }; @@ -790,7 +1056,7 @@ mod tests { } #[test] - #[should_panic(expected: ('Unauthorized call',))] + #[should_panic(expected: ('Caller is not the owner',))] fn collection_transfer_ownership_as_not_admin() { let BRIDGE_L1 = EthAddress { address: 'starklane_l1' }; let OWNER_L1: EthAddress = 0xe00.try_into().unwrap(); diff --git a/apps/blockchain/starknet/src/token/erc721_bridgeable.cairo b/apps/blockchain/starknet/src/token/erc721_bridgeable.cairo index b6938697..9ec9419c 100644 --- a/apps/blockchain/starknet/src/token/erc721_bridgeable.cairo +++ b/apps/blockchain/starknet/src/token/erc721_bridgeable.cairo @@ -9,7 +9,7 @@ mod erc721_bridgeable { use openzeppelin::token::erc721::ERC721Component; use openzeppelin::access::ownable::OwnableComponent; - use starklane::token::interfaces::{IERC721Bridgeable, IERC721Mintable}; + use starklane::token::interfaces::{IERC721Bridgeable, IERC721Mintable, IERC721Uri}; use starklane::interfaces::IUpgradeable; component!(path: ERC721Component, storage: erc721, event: ERC721Event); @@ -154,6 +154,24 @@ mod erc721_bridgeable { self.token_uris.write(token_id, token_uri); } } + + #[abi(embed_v0)] + impl ERC721UriImpl of IERC721Uri { + fn base_uri(self: @ContractState) -> ByteArray { + self.erc721._base_uri() + } + + fn set_base_uri(ref self: ContractState, base_uri: ByteArray) { + self.ownable.assert_only_owner(); + self.erc721._set_base_uri(base_uri); + } + + fn set_token_uri(ref self: ContractState, token_id: u256, token_uri: ByteArray) { + self.ownable.assert_only_owner(); + assert(self.erc721._exists(token_id), 'ERC721: invalid token ID'); + self.token_uris.write(token_id, token_uri); + } + } } #[cfg(test)] @@ -168,6 +186,7 @@ mod tests { IERC721BridgeableDispatcher, IERC721BridgeableDispatcherTrait, IERC721Dispatcher, IERC721DispatcherTrait, IERC721MintableDispatcher, IERC721MintableDispatcherTrait, + IERC721UriDispatcher, IERC721UriDispatcherTrait, }; use starklane::token::collection_manager; @@ -402,4 +421,95 @@ mod tests { ownable.transfer_ownership(BOB); stop_prank(CheatTarget::One(contract_address)); } + + #[test] + fn test_set_base_uri() { + let COLLECTION_OWNER = collection_owner_addr_mock(); + let new_uri = "https://this.is.a.test.com"; + let contract_address = deploy_everai_collection(); + + let contract = IERC721UriDispatcher { contract_address}; + assert_eq!(contract.base_uri(), "https://my.base.uri"); + start_prank(CheatTarget::One(contract_address), COLLECTION_OWNER); + contract.set_base_uri(new_uri.clone()); + stop_prank(CheatTarget::One(contract_address)); + assert_eq!(contract.base_uri(), new_uri); + } + + #[test] + #[should_panic(expected: ('Caller is not the owner',))] + fn should_panic_set_base_uri_not_owner() { + let COLLECTION_OWNER = collection_owner_addr_mock(); + let ALICE = starknet::contract_address_const::<'alice'>(); + + let contract_address = deploy_everai_collection(); + + let ownable = IOwnableTwoStepDispatcher { contract_address }; + assert_eq!(ownable.owner(), COLLECTION_OWNER, "bad owner"); + + start_prank(CheatTarget::One(contract_address), ALICE); + IERC721UriDispatcher { contract_address}.set_base_uri("https://this.is.a.test.com"); + stop_prank(CheatTarget::One(contract_address)); + } + + #[test] + fn test_set_token_uri() { + let COLLECTION_OWNER = collection_owner_addr_mock(); + let ALICE = starknet::contract_address_const::<'alice'>(); + let new_uri = "https://this.is.a.test.com/68"; + let contract_address = deploy_everai_collection(); + let token_id = 42_u256; + + start_prank(CheatTarget::One(contract_address), COLLECTION_OWNER); + IERC721MintableDispatcher { contract_address}.mint(ALICE, token_id); + stop_prank(CheatTarget::One(contract_address)); + assert!(IERC721Dispatcher {contract_address}.token_uri(token_id) != new_uri.clone()); + + start_prank(CheatTarget::One(contract_address), COLLECTION_OWNER); + IERC721UriDispatcher { contract_address}.set_token_uri(token_id, new_uri.clone()); + stop_prank(CheatTarget::One(contract_address)); + assert_eq!(IERC721Dispatcher {contract_address}.token_uri(token_id), new_uri); + } + + #[test] + #[should_panic(expected: 'ERC721: invalid token ID',)] + fn test_set_token_uri_for_invalid_token_id() { + let COLLECTION_OWNER = collection_owner_addr_mock(); + let ALICE = starknet::contract_address_const::<'alice'>(); + let new_uri = "https://this.is.a.test.com/68"; + let contract_address = deploy_everai_collection(); + let token_id = 42_u256; + let invalid_token_id = 68_u256; + + start_prank(CheatTarget::One(contract_address), COLLECTION_OWNER); + IERC721MintableDispatcher { contract_address}.mint(ALICE, token_id); + stop_prank(CheatTarget::One(contract_address)); + assert!(IERC721Dispatcher {contract_address}.token_uri(token_id) != new_uri.clone()); + + start_prank(CheatTarget::One(contract_address), COLLECTION_OWNER); + IERC721UriDispatcher { contract_address}.set_token_uri(invalid_token_id, new_uri.clone()); + stop_prank(CheatTarget::One(contract_address)); + assert_eq!(IERC721Dispatcher {contract_address}.token_uri(token_id), new_uri); + } + + #[test] + #[should_panic(expected: 'Caller is not the owner',)] + fn should_panic_set_token_uri_not_owner_of_collection() { + let COLLECTION_OWNER = collection_owner_addr_mock(); + let ALICE = starknet::contract_address_const::<'alice'>(); + let new_uri = "https://this.is.a.test.com/68"; + let contract_address = deploy_everai_collection(); + let token_id = 42_u256; + + start_prank(CheatTarget::One(contract_address), COLLECTION_OWNER); + IERC721MintableDispatcher { contract_address}.mint(ALICE, token_id); + stop_prank(CheatTarget::One(contract_address)); + assert!(IERC721Dispatcher {contract_address}.token_uri(token_id) != new_uri.clone()); + + start_prank(CheatTarget::One(contract_address), ALICE); + IERC721UriDispatcher { contract_address}.set_token_uri(token_id, new_uri.clone()); + stop_prank(CheatTarget::One(contract_address)); + assert_eq!(IERC721Dispatcher {contract_address}.token_uri(token_id), new_uri); + } + } diff --git a/apps/blockchain/starknet/src/token/interfaces.cairo b/apps/blockchain/starknet/src/token/interfaces.cairo index 0fd8814c..5d27fcf2 100644 --- a/apps/blockchain/starknet/src/token/interfaces.cairo +++ b/apps/blockchain/starknet/src/token/interfaces.cairo @@ -13,6 +13,13 @@ trait IERC721 { fn approve(ref self: T, to: ContractAddress, token_id: u256); } +#[starknet::interface] +trait IERC721Uri { + fn base_uri(self: @T) -> ByteArray; + fn set_base_uri(ref self: T, base_uri: ByteArray); + fn set_token_uri(ref self: T, token_id: u256, token_uri: ByteArray); +} + #[starknet::interface] trait IERC721Mintable { fn mint(ref self: T, to: ContractAddress, token_id: u256);