Logo
blank Skip to main content

Scaling Ethereum NFT Transactions Using Starknet: Practical Example

Blockchains and non-fungible tokens (NFTs) offer innovative ways to manage digital assets. However, these systems often force a trade-off between security and scalability. Additionally, scaling NFT systems comes with a significant increase in transaction costs, which can become too expensive over time.

But what if you didn’t have to choose between security and scalability? In this article, we explore a way to achieve scalability and security using Starknet smart contracts and compare the performance and efficiency of this solution with a pure Ethereum setup using a practical example. 

This guide will be useful for product and technical leaders looking to optimize their dApps for scalability without sacrificing security or breaking the bank on transaction fees.

Scalability challenges of Ethereum-based NFTs

Ethereum is one of the most popular open-source, decentralized networks that facilitates smart contracts and supports NFTs.

Businesses use NFTs to create and manage unique digital assets across industries such as art, collectibles, and gaming. Thanks to their properties like immutability, transparency, provable ownership, and rarity, NFTs bring unique benefits regardless of the industry, enabling businesses to offer secure and exclusive products or services, enhance customer engagement, and create new revenue streams.

how businesses benefit from NFTs

Despite these benefits, Ethereum suffers from scalability issues, particularly on Layer 1 (L1) โ€” the base blockchain layer where transactions are processed directly on the Ethereum network. This leads to high transaction fees as activity increases. 

If you decide to adopt Ethereum-based NFTs, thereโ€™s a risk that the more users and transactions your app has, the higher the fees will be. This is where you can use Layer 2 (L2) solutions โ€” secondary frameworks built on top of L1 to process transactions off-chain and improve scalability. One of the examples of L2 solutions is Starknet.

Need to balance scalability and security in your dApp?

Reach out to our blockchain experts and achieve a scalable and secure blockchain solution.

Scaling Ethereum NFT dApps using Starknet

What is Starknet, and how does Ethereum scale with it?

Starknet is a Layer 2 network that operates on top of Ethereum, allowing dApps to scale without compromising security. By processing transactions off-chain and only submitting proofs to Ethereum, Starknet reduces transaction costs and increases efficiency.

This is achieved through STARK proofs, where multiple transactions are bundled into a single off-chain computation. The resulting proof is submitted to Ethereum as one transaction, significantly increasing throughput.

Like Ethereum, Starknet executes smart contracts, but it uses a unique programming language called Cairo โ€” a Rust-typed language with immutable memory access, meaning that once data is written, it cannot be altered.

In this article, we demonstrate how Starknet scales Ethereum and reduces transaction costs using the example of a golf club membership management system that issues NFT memberships to clients.

Read also

Does Your Business Need NFTs? Use Cases, Benefits, and Nuances to Consider

Discover how NFTs can be used as security tokens, collectibles, and proof of ownership to gain a competitive edge.

Learn more
nft-for-business.png

How to scale an Ethereum dApp using Starknet: practical example

We will develop two golf club membership management applications โ€“ one on Ethereum and one on Starknet โ€“ to compare their speed, scalability, and cost. From a functional standpoint, the two systems will be identical: both will implement basic processes for creating a token, transferring it to a new owner, and redeeming it for associated benefits.

The steps in our appโ€™s business logic are as follows:

  1. A client of the golf club network wants to purchase a token (NFT) representing a certain membership level.
  2. The golf club mints the token according to the request and sends it to the client.
  3. The client makes a payment in Ether and receives the NFT.
  4. The golf club receives the payment.
  5. The client can then use the NFT to purchase passes and access perks and discounts.

To implement this concept, weโ€™ll create smart contracts using two programming languages: Solidity for deploying the contract on Ethereum and Cairo for deploying it on Starknet. 

Our example is based on Solidity version 0.8.24 and Cairo version 2.7.1, but as these languages are constantly evolving, you may need to adjust the code for newer versions.

The system will consist of two contracts: GolfClubNFT for the NFT membership and MembershipActions for invoking methods from the NFT contract to perform actions like creating, transferring, and managing membership.

Implementing NFT smart contracts with Ethereum

In our example, the NFT corresponds to the ERC721 standard, with its implementation borrowed from OpenZeppelin ERC721. The ERC721 standard specifies unique tokens on the Ethereum blockchain and is used to create NFTs representing unique assets.

To implement Ethereum smart contracts, follow these steps:

  1. Create the NFT smart contract interface
  2. Implement the MembershipActions contract
  3. Store and manage variables
  4. Mint and sell NFT membership
  5. Enable NFT use
  6. Implement membership upgrades
  7. Enable clients to play a golf game

Letโ€™s now go through this process with a detailed description of each step.

Step 1: Create the NFT smart contract interface

The public functions of the smart contract will enable minting (creating) and burning (destroying) tokens. In addition to these functions will be others that modify token attributes. The interface will look as follows:

Solidity
interface IGolfClubMembershipNFT is IERC721{ 
	struct Membership { 
    	uint8 level; 
    	uint8 visitsAvailable; 
    	uint16 clubVisits; 
    	uint8 freeEquipBonus; 
    	uint8 freeLessonBonus; 
	} 
	 
	function mint( 
    	address recipient, 
    	uint256 tokenId, 
    	Membership memory attr 
	) external; 
 
	function burn(uint256 tokenId) external; 
 
	function addVisits(uint256 tokenId, uint8 visits) external; 
 
	function useFreeEquip(uint256 tokenId) external; 
 
	function useFreeLesson(uint256 tokenId) external; 
 
	function upgradeMembershipLevel(uint256 tokenID, uint8 _level) external; 
 
	function useVisits(uint256 tokenId) external; 
 
	function getLevel(uint256 tokenId) external returns(uint8); 
 
	function getCounter() external returns(uint256); 
 
	function getTokenInfo(uint256 tokenId) external returns (Membership memory); 
 
	function isTokenExist(uint256 tokenId)external view returns (bool); 
} 

We will restrict some functions so that users canโ€™t access other usersโ€™ accounts and perform actions on their behalf. In our case, only the MembershipActions contract can call certain functions. To enforce this, we create a modifier called membershipOnly.

Step 2: Implement the MembershipActions contract

The next step is to create a contract that allows us to interact with the GolfClubNFT. We will again restrict access to certain functions using an Ownable smart contract. This smart contract is provided by OpenZeppelin and controls access to contract functions, limiting their execution to only the contract owner.

The following interface includes external functions and events that will be emitted when a function is executed:

Solidity
interface IMembershipActions{ 
  
	struct Membership { 
    	uint8 level; 
    	uint8 visitsAvailable; 
    	uint16 clubVisits; 
    	uint8 freeEquipBonus; 
    	uint8 freeLessonBonus; 
	} 
	event NFTContractSet(address _contract); 
 
	event PassesPurchased(address receiver, uint256 tokenId, uint8 amountPasses); 
 
	event VisitRecorded(uint256 tokenId); 
 
	event NFTCreated(address receiver, uint256 tokenId); 
 
	event UpgradedLevel(uint256 tokenId, uint256 levelBefore, uint256 levelAfter); 
 
	event BonusFreeLessonUsed(uint256 tokenId); 
 
	event BonusFreeEquipUsed(uint256 tokenId); 
 
	event SendMessage(uint256 contractAddress, uint256 value); 
 
	event MessageReceived(uint256 fromAddress, uint256 value); 
 
	function setGolfClubNFTContract(address _contract) external; 
 
	function upgradeLevel(uint256 id, uint8 level) external payable; 
 
	function claimMembership( 
    	address recipient, 
    	uint256 tokenId, 
    	uint8 level 
	) external payable; 
 
	function buyPasses(uint256 tokenId, uint8 amount)external payable; 
 
	function playGame(uint256 tokenId) external; 
 
	function usingFreeLesson(uint256 tokenId) external; 
 
	function usingFreeEquip(uint256 tokenId) external; 
 
	function getPrice(uint8 level) external returns (uint256); 
 
} 

 Step 3: Store and manage variables

Now, we need to set and store the GolfClubNFT contract address so we can interact with it for minting tokens and transferring them to buyers.

Solidity
function setGolfClubNFTContract(address _contract) external onlyOwner{ 
    	require(golfClubNFTContract == address(0), "NFT_CONTRACT_ALREADY_SET"); 
    	require(_contract != address(0), "NFT_ADDR_IS_ZERO"); 
    	golfClubNFTContract = _contract; 
    	emit NFTContractSet(_contract); 
	}

We also need modifiers that will check whether the NFT contract has been set and if a token with the specified ID exists. If the address hasnโ€™t been set, a modifier will help us avoid errors. To create modifiers, letโ€™s use the following code:

Solidity
modifier golfClubNFTContractIsSet() { 
    	require(golfClubNFTContract != address(0), 'NFT_CONTRACT_NOT_SET'); 
    	_; 
	} 
	modifier tokenExist(uint256 tokenId){ 
    	require(_ownerOf(tokenId) != address(0), "TOKEN_DOES_NOT_EXIST"); 
    	_; 
	}

Step 4: Mint and sell NFT membership

Now we create a function that will allow us to mint (create) a token and transfer it to the client using their wallet address:

Solidity
function claimMembership( 
    	address recipient, 
    	uint256 tokenId, 
    	uint8 level 
	) public payable golfClubNFTContractIsSet{ 
    	require(level <= 2, "INVALID_LEVEL"); 
    	uint256 price = getPrice(level); 
 
    	require(msg.value >= price, "INSUFFICIENT_FUNDS"); 
 
    	IGolfClubMembershipNFT.Membership memory attr = IGolfClubMembershipNFT.Membership({ 
        	level: level, 
        	visitsAvailable: 0, 
        	clubVisits: 0, 
        	freeEquipBonus: 0, 
        	freeLessonBonus: 0 
    	}); 
 
    	IGolfClubMembershipNFT(golfClubNFTContract).mint(recipient, tokenId, attr); 
    	emit NFTCreated(recipient, tokenId); 
 
    	if(msg.value > price){ 
        	payable(msg.sender).transfer(msg.value - price); 
    	} 
	}

Only the desired NFT membership level (Standard, Premium, or VIP) is passed as a parameter. All other attributes, except for token ID, will be set to 0 until one of the events (such as purchasing passes or playing golf) occurs.

Step 5: Enable NFT use

Now, letโ€™s allow the client to use the NFT. Weโ€™ll begin by implementing the buyPasses function :

Solidity
function buyPasses(uint256 tokenId, uint8 amount)external payable onlyOwner golfClubNFTContractIsSet{ 
    	require(amount == 5 || amount == 10 || amount == 15, "INVALID_PASSES_QUANTITY"); 
    	IGolfClubMembershipNFT.Membership memory membership = IGolfClubMembershipNFT(golfClubNFTContract) 
        	.getTokenInfo(tokenId); 
    	uint256 requiredPrice; 
 
    	if (amount == 5) { 
        	requiredPrice = FIVE_GAME_PRICE; 
    	} else if (amount == 10) { 
        	requiredPrice = TEN_GAME_PRICE; 
    	} else if (amount == 15) { 
        	requiredPrice = FIFTEEN_GAME_PRICE; 
    	} 
 
    	if (membership.clubVisits > 20) { 
        	if(membership.level == 0){ 
            	requiredPrice -= requiredPrice * 3 / 100; 
        	}else if(membership.level == 1){ 
            	requiredPrice -= requiredPrice * 5 / 100; 
        	}else{ 
            	requiredPrice -= requiredPrice * 7 / 100; 
        	} 
    	} 
 
    	require(msg.value >= requiredPrice, "INSUFFICIENT_FUNDS"); 
 
    	IGolfClubMembershipNFT(golfClubNFTContract).addVisits(tokenId, amount); 
    	emit PassesPurchased(msg.sender, tokenId, amount); 
 
    	if(msg.value > requiredPrice){ 
        	payable(msg.sender).transfer(msg.value - requiredPrice); 
    	} 
	}

This functionโ€™s parameters are:

  • tokenId: the identifier of the token that will receive the purchased passes.
  • amount: the number of passes purchased (a user can buy 5, 10, or 15 passes).

The claimMembership and buyPasses functions are declared payable because we expect to receive Ether as payment from the client. When purchasing, the function performs several checks. For example, the following checks are made in the buyPasses function:

  • The number of passes purchased is 5, 10, or 15.
  • If the client has more than 20 total golf club visits, a discount of 3%, 5%, or 7% is applied, depending on the membership level.
  • The amount of Ether sent by the client matches the required amount. If the client sends more Ether than needed, the excess will be refunded.
  • The addVisits function ensures that the sum of existing and purchased passes does not exceed 25.

If all checks are passed, the client will receive the purchased game passes.

Step 6: Implement membership upgrades

Now, we need to enable the client to upgrade their membership level to receive more bonuses. To do this, letโ€™s create the upgradeLevel function.

Solidity
function upgradeLevel(uint256 tokenId, uint8 level) external payable onlyOwner golfClubNFTContractIsSet{ 
    	require(level == 1 || level == 2, "INVALID_LEVEL"); 
    	IGolfClubMembershipNFT.Membership memory membership = 
        	IGolfClubMembershipNFT(golfClubNFTContract).getTokenInfo(tokenId); 
 
    	uint256 requiredPrice; 
    	if (level == 1) { 
        	requiredPrice = UPGRADE_STANDARD_TO_PREMIUM; 
    	} else if (level == 2) { 
        	if (membership.level == 0) { 
            	requiredPrice = UPGRADE_STANDARD_TO_VIP; 
        	} else if (membership.level == 1) { 
            	requiredPrice = UPGRADE_PREMIUM_TO_VIP; 
        	} else { 
            	revert("INVALID_LEVEL_UPGRADE"); 
        	} 
    	} 
 
    	if (membership.clubVisits > 20) { 
        	if(membership.level == 0){ 
            	requiredPrice -= requiredPrice * 3 / 100; 
        	}else if(membership.level == 1){ 
            	requiredPrice -= requiredPrice * 5 / 100; 
        	}else{ 
            	requiredPrice -= requiredPrice * 7 / 100; 
        	} 
    	} 
 
    	require(msg.value >= requiredPrice, "INSUFFICIENT_FUNDS"); 
 
        IGolfClubMembershipNFT(golfClubNFTContract).upgradeMembershipLevel(tokenId, level); 
    	emit UpgradedLevel(tokenId, membership.level, level); 
 
    	if(msg.value > requiredPrice){ 
        	payable(msg.sender).transfer(msg.value - requiredPrice); 
    	} 
	}

Step 7. Implement bonuses

To encourage customers to purchase NFTs, we need to implement bonuses. In our case, a client who owns a Premium-level NFT receives one free equipment use for every five passes purchased, while a VIP-level member gets one free golf lesson for every ten passes purchased. This is what it looks like in code:

Solidity
function usingFreeLesson(uint256 tokenId) external onlyOwner{ 
    	IGolfClubMembershipNFT(golfClubNFTContract).useFreeLesson(tokenId); 
    	emit BonusFreeLessonUsed(tokenId); 
	} 
 
	function usingFreeEquip(uint256 tokenId) external onlyOwner{ 
    	IGolfClubMembershipNFT(golfClubNFTContract).useFreeEquip(tokenId); 
    	emit BonusFreeEquipUsed(tokenId); 
	}

These two functions, usingFreeLesson and usingFreeEquip, invoke methods from the GolfClubMembershipNFT.sol contract to check the availability of corresponding passes and bonuses. 

Solidity
function useFreeEquip(uint256 tokenId) external membershipOnly tokenExist(tokenId){ 
    	require(memberships[tokenId].level != STANDARD_LEVEL, "INSUFFICIENT_MEMBERSHIP"); 
    	require(memberships[tokenId].freeEquipBonus > 0, "NOT_AVAILABLE_FREE_EQUIP_BONUS"); 
    	memberships[tokenId].freeEquipBonus -= 1; 
    	useVisits(tokenId); 
	} 
 
	function useFreeLesson(uint256 tokenId) external membershipOnly tokenExist(tokenId){ 
    	require(memberships[tokenId].level == VIP_LEVEL, "INSUFFICIENT_MEMBERSHIP"); 
    	require(memberships[tokenId].freeLessonBonus > 0, "NOT_AVAILABLE_FREE_LESSON_BONUS"); 
    	memberships[tokenId].freeLessonBonus -= 1; 
    	useVisits(tokenId); 
	}

Step 8. Enable clients to play a golf game

Finally, itโ€™s time to provide the ability to play a game using the membership token. This function can also be called only by the token owner, and only the token ID is transferred as a parameter.  

Solidity
function playGame(uint256 tokenId) external onlyOwner{ 
    	IGolfClubMembershipNFT(golfClubNFTContract).useVisits(tokenId) ; 
    	emit VisitRecorded(tokenId); 
	}

Inside the playGame function, we call the useVisits function from the NFT contract:

Solidity
function _useVisits(uint256 tokenId) internal{ 
    	// every 18th is free 
    	if((memberships[tokenId].clubVisits + 1) % 18 != 0){ 
        	require(memberships[tokenId].visitsAvailable > 0, "NOT_AVAILABLE_VISITS"); 
        	memberships[tokenId].visitsAvailable -= 1; 
    	} 
    	memberships[tokenId].clubVisits += 1; 
	}

All users who have purchased an NFT, regardless of their membership level, can play every 18th game for free. 

Therefore, useVisits first checks the count of games played at the club โ€” if the game number is a multiple of 18, no pass is deducted, but the visit statistics are updated. If the next game is not a multiple of 18, the system will check the availability of passes, and if successful, the number of passes will be reduced by one.

At this point, our Ethereum application is ready. Letโ€™s now build a Starknet smart contract on top of this functionality.

Related project

Developing Smart Contracts for Creating and Selling NFT Images

Discover how our blockchain experts helped an EU-based startup build an efficient NFT art project. .

Project details
Developing Smart Contracts for Creating and Selling NFT Images

Implementing smart contracts with Starknet

Creating a Starknet smart contract requires the following steps:

  1. Define dependencies and the contract interface
  2. Create and initialize an NFT contract
  3. Create a Membership contract
  4. Establish messaging between L1 and L2

Letโ€™s explore the full process in detail.

Step 1. Define dependencies and the contract interface

Weโ€™ll define dependencies in the Scarb.toml file, which includes the compiler version and a link to the OpenZeppelin repository.

TOML
[dependencies] 
starknet = ">=2.7.1" 
openzeppelin = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v0.9.0" }

Our contract interface consists of functions that are available for public use, such as getters, setters, and token manipulations like minting and burning:

Cairo
#[starknet::interface] 
pub trait IGolfClubNft <TContractState>{ 
	fn mint_nft(ref self: TContractState, to: ContractAddress, token_id: u256, attr: Attributes, is_l1_mint: bool); 
	fn burn_nft(ref self: TContractState, token_id: u256); 
	fn add_visits(ref self: TContractState, token_id: u256, visits: u8); 
	fn use_free_equip(ref self: TContractState, token_id: u256); 
	fn use_free_lesson(ref self: TContractState, token_id: u256); 
	fn upgrade_membership_level(ref self: TContractState, token_id: u256, new_level: u8); 
	fn use_visits(ref self: TContractState, token_id: u256); 
	fn get_owner(self: @TContractState, token_id: u256) -> ContractAddress; 
	fn get_token_info(self: @TContractState, token_id: u256) -> Attributes; 
} 

Step 2. Create and initialize an NFT contract

The actual implementation of the contract must match its interface. You will receive a compilation error if your contract does not comply with the declared interface. In the [starknet::contract] section, import the ERC721 and SRC5 modules from the OpenZeppelin library. The storage and event list will also include data from the imported modules.

During contract deployment, the constructor is executed automatically to ensure correct contract deployment. We will add the token name and symbol to the constructor. The NFT will be initialized using the initializer function from ERC721. Additionally, we will include the address of the Membership contract that can invoke GolfNFT

Cairo
#[constructor] 
	fn constructor(ref self: ContractState, membership_addr: ContractAddress) { 
    	self.erc721.initializer('GolfClubMembership', 'GCM'); 
    	self.membership_address.write(membership_addr); 
	}

Implementation of the NFT contract is divided into two parts: the first implements functions from the interface, while the second contains internal functions. Almost all functions of the NFT contract will have restricted access, callable only by the Membership contract that we create below: 

Cairo
fn is_membership(self: @ContractState) { 
    	assert(get_caller_address() == self.membership_address.read(), 'NOT_MEMBERSHIP_CONTRACT'); 
	}

Here, we create the two main token functions โ€” minting and burning:

Cairo
fn mint_nft( 
        	ref self: ContractState, 
        	to: ContractAddress, 
        	token_id: u256, 
        	attr: Attributes, 
        	is_l1_mint: bool 
    	) { 
        	is_membership(@self); 
 
        	if (is_l1_mint) { 
            	self._create_nft(token_id, to, attr) 
        	} else { 
            	let empty_attr = Attributes { 
                	level: attr.level, 
                	visits_available: 0, 
                	club_visits: 0, 
                	free_equip_bonus: 0, 
                	free_lesson_bonus: 0, 
            	}; 
            	self._create_nft(token_id, to, empty_attr); 
        	} 
    	} 
 
    	fn burn_nft(ref self: ContractState, token_id: u256) { 
        	is_membership(@self); 
        	assert(self.erc721._exists(token_id), 'INVALID_TOKEN_ID'); 
 
        	let empty_attr = Attributes { 
            	level: 0, 
            	visits_available: 0, 
            	club_visits: 0, 
            	free_equip_bonus: 0, 
            	free_lesson_bonus: 0, 
        	}; 
        	self.attributes.write(token_id, empty_attr); 
        	self.erc721._burn(token_id); 
    	}

When creating a token, we record the same attributes as in Ethereum contracts: membership level, number of visits, passes, and available bonuses.

Cairo
fn upgrade_membership_level(ref self: ContractState, token_id: u256, new_level: u8) { 
        	is_membership(@self); 
        	assert(self.erc721._exists(token_id), 'INVALID_TOKEN_ID'); 
 
        	self._set_membership_level(token_id, new_level); 
    	} 
 
 
    	fn _set_membership_level(ref self: ContractState, token_id: u256, new_level: u8) { 
        	let mut token_attributes = self.attributes.read(token_id); 
        	assert(new_level <= VIP_LEVEL, 'INVALID_NEW_LEVEL'); 
        	assert(token_attributes.level != new_level, 'FAIL_UPGRADED_TO_THE_SAME_LEVEL'); 
        	assert(token_attributes.level < new_level, 'CANNOT_BE_DOWNGRADED_LEVEL'); 
 
        	token_attributes.level = new_level; 
        	self.attributes.write(token_id, token_attributes);  	}

In contrast to contracts written in Solidity, we donโ€™t check the amount of funds the buyer sends. The sale of NFTs and other paid functions occurs through a multi-call, where the token and funds are transferred separately. The multi-call was not developed for this example, as it does not impact our comparison of the two networks.

Step 3. Create the Membership contract

Similarly to the implementation in Ethereum, we will now create a contract that allows for interaction with GolfClubNFT.

Cairo
#[starknet::interface] 
trait IMembershipActions <TContractState> { 
	fn set_nft_contract(ref self: TContractState, nft_addr: ContractAddress); 
	fn set_l1_sender(ref self: TContractState, l1_addr: felt252); 
	fn upgrade_level(ref self: TContractState, token_id: u256, new_level: u8); 
	fn claim_nft(ref self: TContractState, recipient_address: felt252, token_id: u256, attributes: Attributes); 
	fn buy_passes(ref self: TContractState, token_id: u256, amount: u8); 
	fn play_game(ref self: TContractState, token_id: u256); 
	fn use_free_lesson(ref self: TContractState, token_id: u256); 
	fn use_free_equip(ref self: TContractState, token_id: u256); 
	fn get_price(self: @TContractState, level: u8) -> u256; 
	fn get_storage_nft_address(self: @TContractState) -> ContractAddress; 
}

Now we have to save the token contract address:

Cairo
fn set_nft_contract(ref self: ContractState, nft_addr: ContractAddress) { 
        	assert(self.nft_contract_address.read().is_zero(), 'NFT_CONTRACT_ALREADY_SET'); 
        	assert(!nft_addr.is_zero(), 'NFT_ADDR_IS_ZERO'); 
 
        	self.nft_contract_address.write(nft_addr); 
        	self.emit(NFTContractSet { _contract: nft_addr }); 
    	}

We wonโ€™t duplicate and reiterate the implementation of functions, as their logic and structure are the same โ€” only in this case, theyโ€™re written in Cairo instead of Solidity. Now, we need to link our Starknet system to Ethereum.

Step 4. Establish messaging between L1 and L2

To transfer a token from Ethereum (L1 Layer) to Starknet (L2 Layer), we must create a bridge. Letโ€™s add the corresponding functionality to the Starknet contract:

Cairo
 #[l1_handler] 
	fn msg_handler_value(ref self: ContractState, from_address: felt252, data: MyData) { 
    	assert(from_address == self.l1_contract_address.read(), 'WRONG_L1_SENDER'); 
 
    	assert(!data.attributes.is_zero(), 'INVALID_MSG_ATTRIBUTES'); 
    	assert(!data.recipient_address.is_zero(), 'INVALID_RECIPIENT_ADDRESS'); 
    	self.emit(MessageReceived { l1_address: from_address, recipient_address: data.recipient_address, attributes:data.attributes }); 
    	let(token_id, attr) = self._deserialize_msg(data.attributes); 
 
    	self.claim_nft(data.recipient_address, token_id, attr); 
	}

The function with the #[l1_handler] attribute is designed to handle L1 messages. L1 handlers are special functions that can only be executed by an L1HandlerTransaction function. No additional action is needed to receive transactions from L1, as messages are passed automatically. It is crucial to verify the sender of the L1 message to ensure that our contract can only receive messages from a trusted L1 contract.

Cairo
fn set_l1_sender(ref self: ContractState, l1_addr: felt252) { 
        	assert(self.l1_contract_address.read().is_zero(), 'L1_SENDER_ADDRESS_ALREADY_SET'); 
        	assert(!l1_addr.is_zero(), 'L1_SENDER_ADDRESS_IS_ZERO'); 
 
        	self.l1_contract_address.write(l1_addr); 
        	self.emit(L1SenderSet { _contract: l1_addr }); 
    	} 

Now, letโ€™s implement the function to send a message from L1 to L2 in our Solidity contract. This functionโ€™s parameters are:

  • contractAddress: Address of the Membership contract in Starknet
  • selector: Selector for the function with the #[l1_handler] attribute
  • tokenId: ID of the token that will be minted in Starknet
  • recipient: Address of the Starknet contract to which the token will be transferred after minting
Cairo
function sendMessageToStarknet( 
    	uint256 contractAddress, 
    	uint256 selector, 
    	uint256 tokenId, 
    	uint256 recipient 
	)external payable { 
    	uint256 value = serialize_msg(tokenId); 
    	uint256[] memory payload = new uint256[](2); 
    	payload[0] = value; 
    	payload[1] = recipient; 
 
    	IStarknetMessaging(_snMessaging).sendMessageToL2{value: msg.value}( 
        	contractAddress, 
        	selector, 
        	payload 
    	); 
    	emit SendMessage(contractAddress, value); 
    	IGolfClubMembershipNFT(golfClubNFTContract).burn(tokenId); 
	}

As mentioned earlier, no special action is required to receive transactions in Starknet from L1. Receiving a transaction in Ethereum from L2 involves two steps: sending and receiving. In the Ethereum MembershipActions contract, we will create a function that uses consumeMessageFromL2() from StarknetCore:

Cairo
	function consumeMessageFromStarknet( 
    	uint256 fromAddress, 
    	uint256[] calldata payload 
	) external{ 
    	_snMessaging.consumeMessageFromL2(fromAddress, payload); 
 
    	if (payload.length != 2) { 
        	revert InvalidPayload(payload.length); 
    	} 
 
    	uint256 value = payload[0]; 
    	uint256 recipientAddress = payload[1]; 
 
    	require(value > 0 && recipientAddress > 0, "INVALID_VALUE"); 
    	(uint256 tokenId, IGolfClubMembershipNFT.Membership memory attr) = deserialize_msg(value); 
    	emit MessageReceived(fromAddress, value); 
 
    	IGolfClubMembershipNFT(golfClubNFTContract).mint(address(uint160(recipientAddress)), tokenId, attr); 
	}

We pack all token data (ID and attributes) into a single uint256 variable to save space. This uint256 data will then be converted to felt252 when sent to Starknet, as felt252 is essentially a subset of uint256 used in Starknet.

Cairo
fn _serialize_msg(self: @ContractState, token_id: u256, value: Attributes) -> felt252 { 
        	let level_u256: u256 = value.level.into(); 
        	let visits_available_u256: u256 = value.visits_available.into(); 
        	let club_visits_u256: u256 = value.club_visits.into(); 
        	let free_equip_bonus_u256: u256 = value.free_equip_bonus.into(); 
        	let free_lesson_bonus_u256: u256 = value.free_lesson_bonus.into(); 
 
        	let send_data: u256 = token_id 
            	+ level_u256 * 100000 
            	+ visits_available_u256 * 10000000 
            	+ club_visits_u256 * 10000000000 
            	+ free_equip_bonus_u256 * 100000000000000 
            	+ free_lesson_bonus_u256 * 100000000000000000; 
 
        	send_data.try_into().unwrap() 
    	}

In Starknet, the received data will be deserialized in the reverse order of how components were packed:

Cairo
fn _deserialize_msg(self: @ContractState, value: felt252) -> (u256, Attributes) { 
        	let mut token_attributes = Attributes { 
            	level: 0, 
            	visits_available: 0, 
            	club_visits: 0, 
            	free_equip_bonus: 0, 
            	free_lesson_bonus: 0, 
        	}; 
        	let mut value_u128: u128 = value.try_into().unwrap(); 
 
        	let _free_lesson: u8 = (value_u128 / 100000000000000000).try_into().unwrap(); 
        	token_attributes.free_lesson_bonus = _free_lesson; 
        	let freeLessonBonusRemainder = value_u128 % 100000000000000000; 
 
        	let _free_equip: u8 = (freeLessonBonusRemainder / 100000000000000).try_into().unwrap(); 
        	token_attributes.free_equip_bonus = _free_equip; 
        	let freeEquipBonusRemainder = freeLessonBonusRemainder % 100000000000000; 
 
        	let _club_visits: u128 = (freeEquipBonusRemainder / 10000000000).try_into().unwrap(); 
        	token_attributes.club_visits = _club_visits; 
        	let clubVisitsRemainder = freeEquipBonusRemainder % 10000000000; 
 
        	let _visits_available: u8 = (clubVisitsRemainder / 10000000).try_into().unwrap(); 
        	token_attributes.visits_available = _visits_available; 
        	let visits_available_remainder = clubVisitsRemainder % 10000000; 
 
        	let _level: u8 = (visits_available_remainder / 100000).try_into().unwrap(); 
        	token_attributes.level = _level; 
 
        	let token_id: u256 = (visits_available_remainder % 100000).into(); 
        	(token_id, token_attributes) 
    	} 

During data processing, the results of all calculations are converted to their respective attribute types.

Now that we have both Ethereum and Starknet applications in place, letโ€™s check if we were successful in scaling Ethereum with Starknet. In the next section, we test and compare the scalability and transaction costs for both solutions.

Read also

NFTs for Real Estate: Exploring the Concept and Creating Tokens in Python, JavaScript, and Solidity

Explore how NFTs can transform the real estate industry by automating operations and reducing fraud. We discuss practical applications of NFTs for selling physical properties and discover key insights to navigate potential pitfalls in creating an NFT-driven marketplace.

Learn more
nft-for-real-estate

Comparing Ethereum vs Starknet smart contracts

Letโ€™s start with simulating the use of NFTs on both networks and compare transaction costs. For Starknet, weโ€™ll also factor in the cost of transferring a token from Ethereum to Starknet and back.

At the time of our comparison, the price of 1 Ether is approximately $2,530. Weโ€™ll create an NFT on Ethereum and perform the following 10 actions:

  1. Buy 10 passes.
  2. Play a round of golf.
  3. Play a round of golf using the free equipment bonus.
  4. Play a round of golf using the free golf lesson bonus.
  5. Play two more rounds of golf.
  6. Play another round of golf using the free equipment bonus.
  7. Buy 5 more passes.
  8. Use the free equipment bonus in the next round.
  9. Transfer the NFT to Starknet.
  10. Play another round of golf.

First, weโ€™ll create an NFT in Ethereum with the index 333.

figure1
Figure 1. Creating a GolfClubMembership token in Ethereum 

After completing the 10 actions, the attributes of the NFT will be as follows:

  • Token ID: 333
  • Token Level: 2
  • Available Club Visits: 7
  • Used Club Visits: 8
  • Available Free Equipment Bonuses: 0
  • Available Free Golf Lesson Bonuses: 0 

Next, weโ€™ll send a message to Starknet with the tokenโ€™s attributes (to be used for minting) and the wallet address on L2 to which the token will be sent.

figure2
Figure 2. Sending token attributes and wallet address to L2 

When the tokenโ€™s data is sent to the L2 network, the token in L1 is burned (when transferring from L2 to L1, the same process occurs).

Hereโ€™s the message we get from Starknet as a result:

figure3
Figure 3. Event for receiving a message from Ethereum 

In the code above, you can see that when the Starknet contract receives a message from L1, it triggers a function that deserializes the received data and calls a function to create a token with the specified attributes.

figure4
Figure 4. Event for creating an NFT based on data received from the Ethereum message

Now, weโ€™ll perform 10 transactions similar to the ones on Ethereum. The costs of all transactions are listed in the table below:

figure5
Figure 5. Comparison of transaction costs between Ethereum and Starknet 

All costs associated with sending messages between L1 and L2 are included in Starknetโ€™s overall expenses. From the table, itโ€™s clear that even with the additional costs of these cross-layer messages, Starknetโ€™s total expenses are almost equal to those of a pure Ethereum setup. However, Starknet comes with a significant advantage: scalability.

Starknet uses ZK-Rollups (Zero-Knowledge Rollups) technology, which bundles many transactions into one, sending proofs to Ethereum. As a result, the per-transaction fee is roughly ten times lower than on Ethereum.

While both solutions might show similar expenses initially, building on Starknet offers substantial long-term cost savings as your dApp grows. If youโ€™re looking to scale your application without skyrocketing transaction costs, Starknet is the better choice over a purely Ethereum-based setup.

Conclusion

As your dApp grows, you may face the Ethereum scaling problem and growing transaction costs. By leveraging solutions like Starknet, you can mitigate these challenges. Starknet provides a scalable, cost-efficient addition to Ethereum, reducing transaction fees without sacrificing security or functionality. 

If you plan to build a product that uses NFTs and other tokenized assets, our blockchain development team can help you optimize transaction costs while keeping your system secure and scalable. 

Enhance your project with Aprioritโ€™s dedicated experts!

Leverage our extensive experience in building robust and innovative blockchain solutions of any complexity.

Have a question?

Ask our expert!

Maryna-Prudka
Maryna Prudka

R&D Delivery Manager

Tell us about your project

Send us a request for proposal! Weโ€™ll get back to you with details and estimations.

Book an Exploratory Call

Do not have any specific task for us in mind but our skills seem interesting?

Get a quick Apriorit intro to better understand our team capabilities.

Book time slot

Contact us