Skip to main content

Analyzing smart contract cost and efficiency

Several factors influence how quickly and efficiently your smart contracts execute on the Stellar network. This guide will help you understand these factors and provide tips on how to write cost-effective contracts.

How to optimize smart contract cost:

Complex contracts with numerous conditions, loops, and computations require more processing power on Stellar. This can lead to higher gas costs (transaction fees) and slower execution times.

1. Efficient loop and storage calls usage

A contract that requires multiple loops and conditions to execute will cost more than a simple contract that executes a single operation.

Not Optimal Contract ❎

#![no_std]
use soroban_sdk::{contract, contractimpl, log, vec, Map, Env};

#[contract]
pub struct ExampleContract;

#[contractimpl]
impl ExampleContract {
// Function to update values in storage inefficiently
pub fn update_values(env: Env, values: Vec<u32>) {
for &value in values.iter() {
let current_count = env.storage().persistent().get("total_count");
env.storage().persistent().set("total_count", &(current_count + value));
}
}
}

danger

Problem: Each iteration of the loop performs a separate read and write operation to update total_count in storage.

Inefficient: This results in multiple expensive storage operations (read and write) within the loop, increasing gas costs significantly as the array size (values.len()) grows.

Optimal Contract ✅

#![no_std]
use soroban_sdk::{contract, contractimpl, log, vec, Env, Map};

#[contract]
pub struct ExampleContract;

#[contractimpl]
impl ExampleContract {
// Function to update values in storage efficiently
pub fn update_values(env: Env, values: Vec<u32>) {
let mut total_count = env.storage().persistent().get("total_count");

for &value in values.iter() {
total_count += value;
}

env.storage().persistent().set("total_count", &total_count);
}
}

tip

In this optimized approach, we'll accumulate the changes outside the loop and perform a single storage update and also a single read operation. This reduces the number of storage operations and the overall gas cost of the contract.

2. Proper use of batch operations

From the first example, we can see that batch operations are more efficient than individual operations. This is because batch operations reduce the number of external calls to the blockchain, which can be costly. However, there are some scenarios where the use of batch operations can be further optimized. Examples are shown below.

Not Optimal Contract ❎

#![no_std]
use soroban_sdk::{contract, contractimpl, Address, Env, Symbol};
use soroban_sdk::token::Client as TokenClient;

#[contract]
pub struct TokenTransferContract;

#[contractimpl]
impl TokenTransferContract {
// Inefficient way: Multiple individual transfers
pub fn transfer_tokens_inefficient(
env: Env,
token: Address,
from: Address,
to: Vec<Address>,
amount_each: i128,
) {
let token_client = TokenClient::new(&env, &token);

for recipient in to.iter() {
token_client.transfer(&from, recipient, &amount_each);
}
}
}
danger

This function performs individual transfers for each recipient. While straightforward, it's inefficient because each transfer is a separate subcontract call, potentially leading to higher gas costs and slower execution.

Optimal Contract ✅

#![no_std]
use soroban_sdk::{contract, contractimpl, Address, Env, Symbol};
use soroban_sdk::token::Client as TokenClient;

#[contract]
pub struct TokenTransferContract;

#[contractimpl]
impl TokenTransferContract {
// Efficient way: Batch transfer
pub fn transfer_tokens_efficient(
env: Env,
token: Address,
from: Address,
to: Vec<Address>,
amount_each: i128,
) {
let token_client = TokenClient::new(&env, &token);
let total_amount = amount_each * (to.len() as i128);

// Perform a single transfer for the total amount
token_client.transfer(&from, &env.current_contract_address(), &total_amount);

// Then distribute from the contract
for recipient in to.iter() {
token_client.transfer(&env.current_contract_address(), recipient, &amount_each);
}
}
}
tip

This function optimizes the process by:

  • First transferring the total amount to the contract itself in a single operation.
  • Then distributing the tokens from the contract to each recipient.

Internal distributions are cheaper due to the reduction in the number of costly external blockchain transactions. By transferring the total amount to the contract and then distributing it internally, the contract minimizes the number of external calls, reducing gas costs and improving efficiency.

3. Use of events over storage

Events are a cost-effective way to store data that doesn't need to be accessed frequently. Events are cheaper than storage operations and can be used to store data that doesn't need to be accessed frequently.

Default Contract

#![no_std]
use soroban_sdk::{
contract, contractimpl, log, symbol_short, vec, Address, Env, Symbol,Map, Vec
};

#[contract]
pub struct GameContract;

#[contractimpl]
impl GameContract {
// Function to record a game move and update storage
pub fn record_move(env: Env, player: Address, move_type: Symbol) {
let mut player_moves: Map<Address, Vec<Symbol>> = env.storage().persistent().get("player_moves");
let moves = player_moves.get(&player);
moves.push(move_type.clone());
player_moves.set(player, moves);
env.storage().persistent().set("player_moves", &player_moves);
}

// Function to unlock an achievement and update storage
pub fn unlock_achievement(env: Env, player: Address, achievement: Symbol) {
let mut player_achievements: Map<Address, Vec<Symbol>> = env.storage().persistent().get("player_achievements");
let achievements = player_achievements.get(&player);
achievements.push(achievement.clone());
player_achievements.set(player, achievements);
env.storage().persistent().set("player_achievements", &player_achievements);
}

}
danger

We cannot store everything in storage like we would do in a traditional database. This approach is not cost-effective as it involves multiple storage operations for each player move and saves each achievement to the storage

Optimized Using Events

#![no_std]
use soroban_sdk::{
contract, contractimpl, log, symbol_short, vec, Address, Env, Symbol,Map, Vec,
};

#[contract]
pub struct GameContract;

#[contractimpl]
impl GameContract {
// Function to record a game move and emit an event
pub fn record_move(env: Env, player: Address, move_type: Symbol) {
// Emit event for the game move
env.events().publish(("game_move",), (&player, move_type.clone()));
}

// Function to unlock an achievement and emit an event
pub fn unlock_achievement(env: Env, player: Address, achievement: Symbol) {
// Emit event for the unlocked achievement
env.events().publish(("achievement_unlocked",), (&player, achievement.clone()));
}
}
tip

In this optimized approach, we use events to store data that doesn't need to be accessed frequently. This reduces the number of storage operations and the overall gas cost of the contract.

Trade-offs:

The current approach using events is indeed optimized for gas efficiency, but it comes with its own set of trade-offs. Let's see different approaches and their implications:

Current Approach (Using Events):

  • Pros:

    • Extremely gas-efficient as it minimizes storage operations
    • Useful for off-chain applications that can listen to and process events
    • Ideal for data that doesn't need to be accessed on-chain frequently
  • Cons:

    • Data is not directly accessible within the contract
    • Relies on external systems to capture and process the event data
    • Not suitable if you need to query or validate past moves within the contract

Storing Latest Move in Persistent Storage and History in Temporary Storage:

This approach offers a balance between accessibility and efficiency.

  • Pros:

    • Latest move is always accessible on-chain
    • Historical data is available within the same transaction
    • More flexible for in-contract logic that might need recent history
  • Cons:

    • Slightly higher gas cost than using only events
    • Temporary storage is cleared after each transaction, so historical data is not permanently accessible on-chain

4. Use of efficient data structures

Heap allocated arrays are slow and costly. Prefer fixed-sized arrays or soroban_sdk::vec!. This is crucial for large arrays, as exceeding the current linear memory size (a multiple of 64KB) triggers wasm32::memory_grow, which is highly computationally intensive.

Example (Heap Allocated Array) ❎
let mut v1 = alloc::vec![];
Example (Fixed-Sized Array) ✅
let mut v2 = [0; 100];

Storing many items in a Vec can be inefficient due to the linear time complexity of membership checks. However, there are some alternatives to having a cumbersome Vec:

Example (Inefficient Data Storage) ❎
#![no_std]
use soroban_sdk::{contract, contractimpl, Address, Env, Symbol, Vec};

#[derive(Clone)]
pub struct PlayerMove {
player: Address,
move_type: Symbol,
}

#[derive(Clone)]
pub struct PlayerAchievement {
player: Address,
achievement: Symbol,
}

#[contract]
pub struct NonOptimalGameContract;

#[contractimpl]
impl NonOptimalGameContract {
// Function to record a move for a specific player
pub fn record_move(env: Env, player: Address, move_type: Symbol) {
let mut all_moves: Vec<PlayerMove> = env.storage().persistent().get("all_moves").unwrap_or_default();
all_moves.push(PlayerMove { player: player.clone(), move_type });
env.storage().persistent().set("all_moves", &all_moves);
}

// Function to get moves for a specific player
pub fn get_moves(env: Env, player: Address) -> Vec<Symbol> {
let all_moves: Vec<PlayerMove> = env.storage().persistent().get("all_moves").unwrap_or_default();
all_moves
.iter()
.filter(|m| m.player == player)
.map(|m| m.move_type.clone())
.collect()
}

// Function to unlock an achievement for a specific player
pub fn unlock_achievement(env: Env, player: Address, achievement: Symbol) {
let mut all_achievements: Vec<PlayerAchievement> = env.storage().persistent().get("all_achievements").unwrap_or_default();
if !all_achievements.iter().any(|a| a.player == player && a.achievement == achievement) {
all_achievements.push(PlayerAchievement { player: player.clone(), achievement });
env.storage().persistent().set("all_achievements", &all_achievements);
}
}

// Function to get achievements for a specific player
pub fn get_achievements(env: Env, player: Address) -> Vec<Symbol> {
let all_achievements: Vec<PlayerAchievement> = env.storage().persistent().get("all_achievements").unwrap_or_default();
all_achievements
.iter()
.filter(|a| a.player == player)
.map(|a| a.achievement.clone())
.collect()
}
}
This non-optimal version has several inefficiencies:
  • Single Vec for All Data:

    All moves and achievements are stored in single Vecs (all_moves and all_achievements), regardless of the player. This means every operation requires loading and saving the entire dataset, which becomes increasingly expensive as the number of entries grows.

  • Linear Search Operations:

    Retrieving moves or achievements for a specific player requires iterating through the entire Vec, which has O(n) time complexity. This becomes very slow and gas-intensive as the number of entries increases.

  • Redundant Data Storage:

    The player's address is stored repeatedly for each move and achievement, leading to unnecessary data duplication.

  • Inefficient Achievement Checking:

    Before adding a new achievement, the code iterates through all achievements to check for duplicates, which is an O(n) operation.

  • Gas Inefficiency:

    Every operation (adding a move, unlocking an achievement, retrieving data) requires loading and saving the entire dataset, which is extremely gas-inefficient.

  • Scalability Issues:

    As the number of players and their actions increase, the performance of this contract will degrade significantly.

  • Lack of Data Separation:

    Moves and achievements are not clearly separated in the storage structure, making it harder to manage and potentially leading to confusion in more complex scenarios.

Alternative: using keyed vecs ✅
#![no_std]
use soroban_sdk::{contract, contractimpl, Address, Env, Symbol, Vec};

#[derive(Clone)]
pub enum DataKey {
Moves(Address),
Achievements(Address),
}

#[contract]
pub struct GameContract;

#[contractimpl]
impl GameContract {
// Function to record a move for a specific player
pub fn record_move(env: Env, player: Address, move_type: Symbol) {
let key = DataKey::Moves(player.clone());
let mut moves: Vec<Symbol> = env.storage().persistent().get(&key).unwrap_or_default();
moves.push(move_type.clone());
env.storage().persistent().set(&key, &moves);
}

// Function to get moves for a specific player
pub fn get_moves(env: Env, player: Address) -> Vec<Symbol> {
let key = DataKey::Moves(player);
env.storage().persistent().get(&key).unwrap_or_default()
}

// Function to unlock an achievement for a specific player
pub fn unlock_achievement(env: Env, player: Address, achievement: Symbol) {
let key = DataKey::Achievements(player.clone());
let mut achievements: Vec<Symbol> = env.storage().persistent().get(&key).unwrap_or_default();
if !achievements.contains(&achievement) {
achievements.push(achievement);
env.storage().persistent().set(&key, &achievements);
}
}

// Function to get achievements for a specific player
pub fn get_achievements(env: Env, player: Address) -> Vec<Symbol> {
let key = DataKey::Achievements(player);
env.storage().persistent().get(&key).unwrap_or_default()
}
}
tip

Here's a breakdown of the optimizations and benefits:

  • Address-Specific Storage:

    Each player's moves and achievements are stored separately, allowing for efficient retrieval of player-specific data. The DataKey enum provides a clear structure for organizing different types of data associated with addresses.

  • Efficient Data Access:

    By using the enum as a key, we can directly access the data for a specific player without needing to search through a larger data structure. This approach is particularly efficient when you need to frequently access or update data for individual players.

  • Flexible Data Structure:

    The use of Vec<Symbol> for both moves and achievements allows for an unlimited number of entries for each player. This structure is suitable for storing ordered lists of moves or unique achievements.

  • Gas Efficiency:

    By storing data separately for each player, we avoid having to load and modify a large, all-encompassing data structure for every operation. This can lead to significant gas savings, especially as the number of players and amount of data grows.

  • Clear Separation of Concerns:

    The enum clearly separates different types of data (moves vs. achievements), making the code more readable and maintainable.

  • Easy Extensibility:

    If you need to add new types of data associated with players, you can easily extend the DataKey enum without changing the existing structure.

5. Use of appropriate env.storage mechanisms

There are three types of storage mechanisms in Stellar:

env.storage().persistent()

Persistent storage is used to store data that needs to be retained across contract invocations. Data stored in persistent storage is maintained between contract invocations and is accessible to all contract functions. Storage for data here is intended to stay in the ledger indefinitely until explicitly deleted. Expired entries can be restored but cannot be recreated.

Use cases

  • Data requiring long-term persistence, such as token balances and user properties.
  • When data needs to be stored indefinitely and must survive even if it expires and needs restoration.
  • Examples: Token balances, user properties.

Cost

  • Highest cost compared to others due to long-term persistence.

Lifetime

  • Data behaves as if it were stored forever but can expire and be restored.

env.storage().temporary()

Temporary storage is used to store data that is only needed during the current contract invocation. Data stored in temporary storage is cleared at the end of the contract invocation and is not accessible to other contract functions. Storage for data here is done with a limited lifespan in the ledger. Entries will be removed after their lifetime ends and can be recreated with different values.

Use cases

  • Data that only needs to exist temporarily, such as oracle data, claimable balances, and offers.
  • When data only needs to exist for a limited time and can be recreated if needed.
  • Examples: Oracle data, claimable balances, offers.

Cost

  • Cheaper than persistent storage due to the limited lifespan of data. (Cheapest cost).

Lifetime

  • Data exists for a predefined period and is then removed.

env.storage().instance()

Storage here is done for a small amount of persistent data tightly coupled with the contract instance. Data is loaded from the ledger every time the contract instance is loaded and it is limited by the ledger entry size, typically in the order of 100 KB serialized.

Use cases

  • Small data directly associated with the contract, such as admin details, configuration settings, and tokens. For small, frequently used data that is integral to the contract instance and benefits from being loaded every time the contract is used.
  • Examples: Contract admin details, configuration settings, tokens operated by the contract.

Cost

  • Likely cheaper than storing data separately in persistent storage.

Lifetime

  • Similar lifetime properties to persistent storage but does not appear in the ledger footprint.

5. Contract size

The size of your smart contract's wasm binary influences the cost of running your smart contract on the Stellar network. Larger wasm binaries require more processing power and memory to execute, leading to higher gas costs. Larger binaries also cost more gas to deploy and invoke.

To optimize the size of your wasm binary, you can:

  • Remove unnecessary code
  • Minimize the use of external dependencies
  • Use built-in tools to optimize the size of your wasm binary

Other tips to optimize smart contract cost:

1. Use of built-in tools

One way to optimize the size of your wasm binary is to use the soroban optimize command. This command comes from the Soroban CLI will optimize the size of your wasm binary by removing unnecessary code and reducing the size of the binary.

stellar contract optimize \
--wasm target/wasm32-unknown-unknown/release/project.wasm

Another way to optimize your smart contract is to use the stellar contract invoke command with the --cost and --sim-only flags. This command will provide you with a detailed breakdown of the cost of running your smart contract on the Stellar network.

stellar contract invoke \
--id CC6MWZMG2JPQEENRL7XVICAY5RNMHJ2OORMUHXKRDID6MNGXSSOJZLLF \
--source alice \
--network testnet \
--cost \
--sim-only \
-- \
increment

Reference: https://sorobandev.com/guides/soroban-cli

2. Manual code review

Perform a manual code review of your smart contract to identify areas where you can optimize the code. Look for redundant loops, conditions, and storage operations that can be minimized or removed.

3. Unit testing with gas measurements

Use unit tests to measure the gas cost of your smart contract functions. This will help you identify functions that are consuming a lot of gas and optimize them accordingly.

Also, using simulateTransaction rpc helper method can give you an insight of the gas cost of your contract functions.

4. Static analysis tools

Tools like Clippy (part of the Rust compiler) can identify potential performance issues during the compilation stage. These tools can warn about:

  • Unnecessary allocations
  • Redundant code

5. Reconsidering storage locations

  • State Variables:

    • Cost: State variables are stored directly on the blockchain's storage. The cost is primarily influenced by the amount of data stored (measured in bytes) and the frequency of read and write operations.
    • Considerations: Writing data to state variables typically incurs higher gas costs compared to reading data. Complex data structures (e.g., arrays, mappings) or large amounts of data increase storage costs and gas consumption. Stellar charges gas fees for each byte of data stored and updated, making efficient data management crucial for cost optimization.
  • Event Logs:

    • Cost: Emitting events in Rust contracts does not incur direct storage costs since events are not permanently stored on the blockchain. Instead, they are included in transaction logs.
    • Considerations: Emitting events consumes gas, primarily due to the computational resources required to execute event emission and log generation. Events are useful for off-chain applications and event-driven architectures but do not contribute directly to on-chain storage costs.
  • External Data Sources:

    • Cost: Interacting with external data sources like oracles involves transaction fees for data retrieval and processing.
    • Considerations: Oracle calls incur gas costs for contract execution, which can vary depending on the complexity and frequency of data fetching. Contract developers should consider gas limits and optimize oracle interactions to minimize costs while ensuring reliable data integration.
  • Immutable Data and Constants:

    • Cost: Constants and immutable variables incur negligible storage costs since they are typically stored as part of the contract's bytecode or metadata.
    • Considerations: Constants and immutable data are crucial for contract configuration and parameterization but do not impact transaction or storage costs significantly. They are pre-defined during contract deployment and do not change during contract execution, avoiding additional gas costs for storage updates.
  • Off-Chain Storage and IPFS:

    • Cost: Storing data off-chain using solutions like IPFS avoids direct on-chain storage costs but incurs costs for data retrieval and IPFS network usage.
    • Considerations: Contracts store only the hash or reference to off-chain data on-chain, minimizing on-chain storage costs. Gas costs may apply when retrieving and processing off-chain data, depending on the complexity and frequency of access. Off-chain storage solutions offer scalability and flexibility but require careful consideration of network fees and data availability.