diff --git a/src/test/vote-BTT/initialize/initialize.t.sol b/src/test/vote-BTT/initialize/initialize.t.sol new file mode 100644 index 000000000..9e9227ac6 --- /dev/null +++ b/src/test/vote-BTT/initialize/initialize.t.sol @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC20Vote } from "contracts/base/ERC20Vote.sol"; + +contract MyVoteERC20 is VoteERC20 { + function eip712NameHash() external view returns (bytes32) { + return _EIP712NameHash(); + } + + function eip712VersionHash() external view returns (bytes32) { + return _EIP712VersionHash(); + } +} + +contract VoteERC20Test_Initialize is BaseTest { + address payable public implementation; + address payable public proxy; + address public token; + uint256 public initialVotingDelay; + uint256 public initialVotingPeriod; + uint256 public initialProposalThreshold; + uint256 public initialVoteQuorumFraction; + + event VotingDelaySet(uint256 oldVotingDelay, uint256 newVotingDelay); + event VotingPeriodSet(uint256 oldVotingPeriod, uint256 newVotingPeriod); + event ProposalThresholdSet(uint256 oldProposalThreshold, uint256 newProposalThreshold); + event QuorumNumeratorUpdated(uint256 oldQuorumNumerator, uint256 newQuorumNumerator); + + function setUp() public override { + super.setUp(); + + // Deploy voting token + token = address(new ERC20Vote(deployer, "Voting VoteERC20", "VT")); + + // Voting param initial values + initialVotingDelay = 5; + initialVotingPeriod = 10; + initialProposalThreshold = 100; + initialVoteQuorumFraction = 50; + + // Deploy implementation. + implementation = payable(address(new MyVoteERC20())); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = payable( + address( + new TWProxy( + implementation, + abi.encodeCall( + VoteERC20.initialize, + ( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ) + ) + ) + ) + ); + } + + function test_initialize_initializingImplementation() public { + vm.expectRevert("Initializable: contract is already initialized"); + VoteERC20(implementation).initialize( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ); + } + + modifier whenNotImplementation() { + _; + } + + function test_initialize_proxyAlreadyInitialized() public whenNotImplementation { + vm.expectRevert("Initializable: contract is already initialized"); + MyVoteERC20(proxy).initialize( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ); + } + + modifier whenProxyNotInitialized() { + proxy = payable(address(new TWProxy(implementation, ""))); + _; + } + + function test_initialize() public whenNotImplementation whenProxyNotInitialized { + MyVoteERC20(proxy).initialize( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ); + + // check state + MyVoteERC20 voteContract = MyVoteERC20(proxy); + + assertEq(voteContract.eip712NameHash(), keccak256(bytes(NAME))); + assertEq(voteContract.eip712VersionHash(), keccak256(bytes("1"))); + + address[] memory _trustedForwarders = forwarders(); + for (uint256 i = 0; i < _trustedForwarders.length; i++) { + assertTrue(voteContract.isTrustedForwarder(_trustedForwarders[i])); + } + + assertEq(voteContract.name(), NAME); + assertEq(voteContract.contractURI(), CONTRACT_URI); + assertEq(voteContract.votingDelay(), initialVotingDelay); + assertEq(voteContract.votingPeriod(), initialVotingPeriod); + assertEq(voteContract.proposalThreshold(), initialProposalThreshold); + assertEq(voteContract.quorumNumerator(), initialVoteQuorumFraction); + assertEq(address(voteContract.token()), token); + } + + function test_initialize_event_VotingDelaySet() public whenNotImplementation whenProxyNotInitialized { + vm.expectEmit(false, false, false, true); + emit VotingDelaySet(0, initialVotingDelay); + MyVoteERC20(proxy).initialize( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ); + } + + function test_initialize_event_VotingPeriodSet() public whenNotImplementation whenProxyNotInitialized { + vm.expectEmit(false, false, false, true); + emit VotingPeriodSet(0, initialVotingPeriod); + MyVoteERC20(proxy).initialize( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ); + } + + function test_initialize_event_ProposalThresholdSet() public whenNotImplementation whenProxyNotInitialized { + vm.expectEmit(false, false, false, true); + emit ProposalThresholdSet(0, initialProposalThreshold); + MyVoteERC20(proxy).initialize( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ); + } + + function test_initialize_event_QuorumNumeratorUpdated() public whenNotImplementation whenProxyNotInitialized { + vm.expectEmit(false, false, false, true); + emit QuorumNumeratorUpdated(0, initialVoteQuorumFraction); + MyVoteERC20(proxy).initialize( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ); + } +} diff --git a/src/test/vote-BTT/initialize/initialize.tree b/src/test/vote-BTT/initialize/initialize.tree new file mode 100644 index 000000000..6b935174b --- /dev/null +++ b/src/test/vote-BTT/initialize/initialize.tree @@ -0,0 +1,30 @@ +initialize( + string memory _name, + string memory _contractURI, + address[] memory _trustedForwarders, + address _token, + uint256 _initialVotingDelay, + uint256 _initialVotingPeriod, + uint256 _initialProposalThreshold, + uint256 _initialVoteQuorumFraction +) +├── when initializing the implementation contract (not proxy) +│ └── it should revert ✅ +└── when it is a proxy to the implementation + └── when it is already initialized + │ └── it should revert ✅ + └── when it is not initialized + └── it should set trustedForwarder mapping to true for all addresses in `_trustedForwarders` ✅ + └── it should correctly set EIP712 name hash and version hash ✅ + └── it should set name to `_name` input param ✅ + └── it should set contractURI to `_contractURI` param value ✅ + └── it should set votingDelay to `_initialVotingDelay` param value ✅ + └── it should emit VotingDelaySet event ✅ + └── it should set votingPeriod to `_initialVotingPeriod` param value ✅ + └── it should emit VotingPeriodSet event ✅ + └── it should set proposalThreshold to `_initialProposalThreshold` param value ✅ + └── it should emit ProposalThresholdSet event ✅ + └── it should set voting token address as the `_token` param value ✅ + └── it should set initial quorum numerator as `_initialVoteQuorumFraction` param value ✅ + └── it should emit QuorumNumeratorUpdated event ✅ + diff --git a/src/test/vote-BTT/other-functions/other.t.sol b/src/test/vote-BTT/other-functions/other.t.sol new file mode 100644 index 000000000..d96e888d2 --- /dev/null +++ b/src/test/vote-BTT/other-functions/other.t.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; +import { IStaking721 } from "contracts/extension/interface/IStaking721.sol"; +import { IERC2981 } from "contracts/eip/interface/IERC2981.sol"; + +import "@openzeppelin/contracts-upgradeable/governance/GovernorUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721ReceiverUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC1155/IERC1155ReceiverUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/introspection/IERC165Upgradeable.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC20Vote } from "contracts/base/ERC20Vote.sol"; + +contract MyVoteERC20 is VoteERC20 {} + +contract VoteERC20Test_OtherFunctions is BaseTest { + address payable public implementation; + address payable public proxy; + + address public token; + uint256 public initialVotingDelay; + uint256 public initialVotingPeriod; + uint256 public initialProposalThreshold; + uint256 public initialVoteQuorumFraction; + + MyVoteERC20 public voteContract; + + function setUp() public override { + super.setUp(); + + // Deploy voting token + vm.prank(deployer); + token = address(new ERC20Vote(deployer, "Voting VoteERC20", "VT")); + + // Voting param initial values + initialVotingDelay = 1; + initialVotingPeriod = 100; + initialProposalThreshold = 10; + initialVoteQuorumFraction = 1; + + // Deploy implementation. + implementation = payable(address(new MyVoteERC20())); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = payable( + address( + new TWProxy( + implementation, + abi.encodeCall( + VoteERC20.initialize, + ( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ) + ) + ) + ) + ); + + voteContract = MyVoteERC20(proxy); + } + + function test_contractType() public { + assertEq(voteContract.contractType(), bytes32("VoteERC20")); + } + + function test_contractVersion() public { + assertEq(voteContract.contractVersion(), uint8(1)); + } + + function test_supportsInterface() public { + assertTrue(voteContract.supportsInterface(type(IERC165).interfaceId)); + assertTrue(voteContract.supportsInterface(type(IERC165Upgradeable).interfaceId)); + assertTrue(voteContract.supportsInterface(type(IERC721ReceiverUpgradeable).interfaceId)); + assertTrue(voteContract.supportsInterface(type(IERC1155ReceiverUpgradeable).interfaceId)); + assertTrue(voteContract.supportsInterface(type(IGovernorUpgradeable).interfaceId)); + + // false for other not supported interfaces + assertFalse(voteContract.supportsInterface(type(IStaking721).interfaceId)); + } +} diff --git a/src/test/vote-BTT/other-functions/other.tree b/src/test/vote-BTT/other-functions/other.tree new file mode 100644 index 000000000..2649d89ae --- /dev/null +++ b/src/test/vote-BTT/other-functions/other.tree @@ -0,0 +1,9 @@ +contractType() +├── it should return bytes32("VoteERC20") ✅ + +contractVersion() +├── it should return uint8(1) ✅ + +supportsInterface(bytes4 interfaceId) +├── it should return true for supported interface ✅ +├── it should return false for not supported interface ✅ diff --git a/src/test/vote-BTT/propose/propose.t.sol b/src/test/vote-BTT/propose/propose.t.sol new file mode 100644 index 000000000..4bdd7f2ad --- /dev/null +++ b/src/test/vote-BTT/propose/propose.t.sol @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC20Vote } from "contracts/base/ERC20Vote.sol"; + +contract MyVoteERC20 is VoteERC20 {} + +contract VoteERC20Test_Propose is BaseTest { + address payable public implementation; + address payable public proxy; + address internal caller; + string internal _contractURI; + + address public token; + uint256 public initialVotingDelay; + uint256 public initialVotingPeriod; + uint256 public initialProposalThreshold; + uint256 public initialVoteQuorumFraction; + + uint256 public proposalIdOne; + address[] public targetsOne; + uint256[] public valuesOne; + bytes[] public calldatasOne; + string public descriptionOne; + + uint256 public proposalIdTwo; + address[] public targetsTwo; + uint256[] public valuesTwo; + bytes[] public calldatasTwo; + string public descriptionTwo; + + MyVoteERC20 internal voteContract; + + event ProposalCreated( + uint256 proposalId, + address proposer, + address[] targets, + uint256[] values, + string[] signatures, + bytes[] calldatas, + uint256 startBlock, + uint256 endBlock, + string description + ); + + function setUp() public override { + super.setUp(); + + // Deploy voting token + vm.prank(deployer); + token = address(new ERC20Vote(deployer, "Voting VoteERC20", "VT")); + + // Voting param initial values + initialVotingDelay = 1; + initialVotingPeriod = 100; + initialProposalThreshold = 10; + initialVoteQuorumFraction = 1; + + // Deploy implementation. + implementation = payable(address(new MyVoteERC20())); + + caller = getActor(1); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = payable( + address( + new TWProxy( + implementation, + abi.encodeCall( + VoteERC20.initialize, + ( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ) + ) + ) + ) + ); + + voteContract = MyVoteERC20(proxy); + _contractURI = "ipfs://contracturi"; + + // mint governance tokens + vm.startPrank(deployer); + ERC20Vote(token).mintTo(caller, 100); + ERC20Vote(token).mintTo(deployer, 100); + vm.stopPrank(); + + // delegate votes to self + vm.prank(caller); + ERC20Vote(token).delegate(caller); + vm.prank(deployer); + ERC20Vote(token).delegate(deployer); + + vm.roll(2); + + // create first proposal + _createProposalOne(); + } + + function _createProposalOne() internal { + descriptionOne = "set proposal one"; + + bytes memory data = abi.encodeWithSelector(VoteERC20.setContractURI.selector, _contractURI); + + targetsOne.push(address(voteContract)); + valuesOne.push(0); + calldatasOne.push(data); + + vm.prank(deployer); + proposalIdOne = voteContract.propose(targetsOne, valuesOne, calldatasOne, descriptionOne); + } + + function _setupProposalTwo() internal { + descriptionTwo = "set proposal two"; + + bytes memory data = abi.encodeWithSelector(VoteERC20.setContractURI.selector, _contractURI); + + targetsTwo.push(address(voteContract)); + valuesTwo.push(0); + calldatasTwo.push(data); + } + + function test_propose_votesBelowThreshold() public { + _setupProposalTwo(); + + vm.prank(address(0x123)); // random address that doesn't have threshold votes + vm.expectRevert("Governor: proposer votes below proposal threshold"); + voteContract.propose(targetsTwo, valuesTwo, calldatasTwo, descriptionTwo); + } + + modifier hasThresholdVotes() { + _; + } + + function test_propose_emptyTargets() public hasThresholdVotes { + address[] memory _targets; + uint256[] memory _values; + bytes[] memory _calldatas; + string memory _description; + + vm.prank(caller); + vm.expectRevert("Governor: empty proposal"); + voteContract.propose(_targets, _values, _calldatas, _description); + } + + modifier whenNotEmptyTargets() { + _; + } + + function test_propose_lengthMismatchTargetsValues() public hasThresholdVotes whenNotEmptyTargets { + _setupProposalTwo(); + + uint256[] memory _values; + + vm.prank(caller); + vm.expectRevert("Governor: invalid proposal length"); + voteContract.propose(targetsTwo, _values, calldatasTwo, descriptionTwo); + } + + modifier whenTargetValuesEqualLength() { + _; + } + + function test_propose_lengthMismatchTargetsCalldatas() + public + hasThresholdVotes + whenNotEmptyTargets + whenTargetValuesEqualLength + { + _setupProposalTwo(); + + bytes[] memory _calldatas; + + vm.prank(caller); + vm.expectRevert("Governor: invalid proposal length"); + voteContract.propose(targetsTwo, valuesTwo, _calldatas, descriptionTwo); + } + + modifier whenTargetCalldatasEqualLength() { + _; + } + + function test_propose_proposalAlreadyExists() + public + hasThresholdVotes + whenNotEmptyTargets + whenTargetValuesEqualLength + whenTargetCalldatasEqualLength + { + // creating proposalOne again + + vm.prank(caller); + vm.expectRevert("Governor: proposal already exists"); + voteContract.propose(targetsOne, valuesOne, calldatasOne, descriptionOne); + } + + modifier whenProposalNotAlreadyExists() { + _; + } + + function test_propose() + public + hasThresholdVotes + whenNotEmptyTargets + whenTargetValuesEqualLength + whenTargetCalldatasEqualLength + whenProposalNotAlreadyExists + { + _setupProposalTwo(); + + vm.prank(caller); + proposalIdTwo = voteContract.propose(targetsTwo, valuesTwo, calldatasTwo, descriptionTwo); + + assertEq(voteContract.proposalSnapshot(proposalIdTwo), voteContract.votingDelay() + block.number); + assertEq( + voteContract.proposalDeadline(proposalIdTwo), + voteContract.proposalSnapshot(proposalIdTwo) + voteContract.votingPeriod() + ); + assertEq(voteContract.proposalIndex(), 2); // because two proposals have been created + assertEq(voteContract.getAllProposals().length, 2); + + ( + uint256 _proposalId, + address _proposer, + uint256 _startBlock, + uint256 _endBlock, + string memory _description + ) = voteContract.proposals(1); + + assertEq(_proposalId, proposalIdTwo); + assertEq(_proposer, caller); + assertEq(_startBlock, voteContract.proposalSnapshot(proposalIdTwo)); + assertEq(_endBlock, voteContract.proposalDeadline(proposalIdTwo)); + assertEq(_description, descriptionTwo); + } + + function test_propose_event_ProposalCreated() + public + hasThresholdVotes + whenNotEmptyTargets + whenTargetValuesEqualLength + whenTargetCalldatasEqualLength + whenProposalNotAlreadyExists + { + _setupProposalTwo(); + uint256 _expectedProposalId = voteContract.hashProposal( + targetsTwo, + valuesTwo, + calldatasTwo, + keccak256(bytes(descriptionTwo)) + ); + string[] memory signatures = new string[](targetsTwo.length); + + vm.startPrank(caller); + vm.expectEmit(false, false, false, true); + emit ProposalCreated( + _expectedProposalId, + caller, + targetsTwo, + valuesTwo, + signatures, + calldatasTwo, + voteContract.votingDelay() + block.number, + voteContract.votingDelay() + block.number + voteContract.votingPeriod(), + descriptionTwo + ); + voteContract.propose(targetsTwo, valuesTwo, calldatasTwo, descriptionTwo); + vm.stopPrank(); + } +} diff --git a/src/test/vote-BTT/propose/propose.tree b/src/test/vote-BTT/propose/propose.tree new file mode 100644 index 000000000..5df017a7e --- /dev/null +++ b/src/test/vote-BTT/propose/propose.tree @@ -0,0 +1,26 @@ +propose( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description +) +├── when caller has votes below proposal threshold + │ └── it should revert ✅ + └── when caller has votes above or equal to proposal threshold + └── when length of `targets` is zero + │ └── it should revert ✅ + └── when length of `targets` is not zero + └── when lengths of `targets` and `values` not equal + │ └── it should revert ✅ + └── when lengths of `targets` and `values` are equal + └── when lengths of `targets` and `calldatas` not equal + │ └── it should revert ✅ + └── when lengths of `targets` and `calldatas` are equal + └── when proposal already exists + │ └── it should revert ✅ + └── when proposal doesn't already exist + └── it should set vote start deadline equal to block number plus voting delay ✅ + └── it should set vote end deadline equal to voting period plus vote start deadline ✅ + └── it should increment proposalIndex by 1 ✅ + └── it should add the new proposal in proposals mapping ✅ + └── it should emit ProposalCreated event ✅ \ No newline at end of file diff --git a/src/test/vote-BTT/set-contract-uri/setContractURI.t.sol b/src/test/vote-BTT/set-contract-uri/setContractURI.t.sol new file mode 100644 index 000000000..cc35af82e --- /dev/null +++ b/src/test/vote-BTT/set-contract-uri/setContractURI.t.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC20Vote } from "contracts/base/ERC20Vote.sol"; + +contract MyVoteERC20 is VoteERC20 {} + +contract VoteERC20Test_SetContractURI is BaseTest { + address payable public implementation; + address payable public proxy; + address internal caller; + string internal _contractURI; + + address public token; + uint256 public initialVotingDelay; + uint256 public initialVotingPeriod; + uint256 public initialProposalThreshold; + uint256 public initialVoteQuorumFraction; + + uint256 public proposalId; + address[] public targets; + uint256[] public values; + bytes[] public calldatas; + string public description; + + MyVoteERC20 internal voteContract; + + function setUp() public override { + super.setUp(); + + // Deploy voting token + vm.prank(deployer); + token = address(new ERC20Vote(deployer, "Voting VoteERC20", "VT")); + + // Voting param initial values + initialVotingDelay = 1; + initialVotingPeriod = 100; + initialProposalThreshold = 10; + initialVoteQuorumFraction = 1; + + // Deploy implementation. + implementation = payable(address(new MyVoteERC20())); + + caller = getActor(1); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = payable( + address( + new TWProxy( + implementation, + abi.encodeCall( + VoteERC20.initialize, + ( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ) + ) + ) + ) + ); + + voteContract = MyVoteERC20(proxy); + _contractURI = "ipfs://contracturi"; + + // mint governance tokens + vm.startPrank(deployer); + ERC20Vote(token).mintTo(caller, 100); + ERC20Vote(token).mintTo(deployer, 100); + vm.stopPrank(); + + // delegate votes to self + vm.prank(caller); + ERC20Vote(token).delegate(caller); + vm.prank(deployer); + ERC20Vote(token).delegate(deployer); + } + + function _createProposalForSetContractURI() internal { + description = "set contract URI"; + + bytes memory data = abi.encodeWithSelector(VoteERC20.setContractURI.selector, _contractURI); + + targets.push(address(voteContract)); + values.push(0); + calldatas.push(data); + + vm.prank(deployer); + proposalId = voteContract.propose(targets, values, calldatas, description); + } + + function test_setContractURI_callerNotAuthorized() public { + vm.prank(address(0x123)); + vm.expectRevert("Governor: onlyGovernance"); + voteContract.setContractURI(_contractURI); + } + + modifier whenCallerAuthorized() { + vm.roll(2); + _createProposalForSetContractURI(); + _; + } + + function test_setContractURI_empty() public whenCallerAuthorized { + vm.roll(10); + // first try execute without votes + vm.expectRevert("Governor: proposal not successful"); + voteContract.execute(targets, values, calldatas, keccak256(bytes(description))); + + // vote on proposal + vm.prank(caller); + voteContract.castVote(proposalId, 1); + + // execute + vm.roll(200); // deadline must be over, before execute can be called + voteContract.execute(targets, values, calldatas, keccak256(bytes(description))); + + // check state: get contract uri + assertEq(voteContract.contractURI(), _contractURI); + } +} diff --git a/src/test/vote-BTT/set-contract-uri/setContractURI.tree b/src/test/vote-BTT/set-contract-uri/setContractURI.tree new file mode 100644 index 000000000..f7819fc38 --- /dev/null +++ b/src/test/vote-BTT/set-contract-uri/setContractURI.tree @@ -0,0 +1,8 @@ +setContractURI(string calldata uri) +├── when caller is not authorized (i.e. execution not going through governance proposals) + │ └── it should revert ✅ + └── when caller is authorized (execution through governance proposals) + └── when `uri` is empty + │ └── it should update contract URI to empty string ✅ + └── when `uri` is not empty + └── it should update contract URI to `uri` ✅ \ No newline at end of file