DEV Community

Cover image for Tutorial on fee estimation for soroban transactions
Rahul Soshte
Rahul Soshte

Posted on • Updated on

Tutorial on fee estimation for soroban transactions

This is a submission for the Build Better on Stellar: Smart Contract Challenge : Create a Tutorial

Tutorial on fee estimation for soroban transactions

Importance of fee estimation

Imagine you're a space explorer(soroban dev) navigating the vast Soroban galaxy(building smart contracts). Fee estimation is your trusty starship's fuel gauge, ensuring you don't end up wasting time on shitty planets ( writing inefficient, expensive code)!!!

Fee estimation is more of a practical knowledge of how much resources that you use of Stellar platform are gonna cost you, and how the network conditions affects the overall fees of the transactions. Armed with this knowledge, you'll be better equipped to build cost-optimized contracts!!!

A basic formula and 2 major fee components

In the Soroban galaxy, every mission(transaction) requires two types of fuel:

  1. Resource fee: This is the cost of resources your smart contract consumes during its voyage. Think of it as the specialized rocket fuel needed for different parts of your mission.
  2. Inclusion fee: Think of this as your space lane toll - the fee you're willing to pay to ensure your transaction gets included in the next available ledger.

The total transaction fee is the sum of these two components:

Transaction Fee = Resource fee + Inclusion fee

But don't let this simple formula fool you! Each of these components has its own intricate calculations and variables. In the following sections, we'll embark on a deep-space exploration of these fee components, uncovering the hidden complexities that can make or break your Soroban missions. Get ready to dive into the quantum mechanics of Soroban fee structures!

Resource fees

There are multiple components in a resource fee, we would further look at them.

CPU instructions

In the Soroban galaxy, your spaceship's engine room(a smart contract’s code) — working tirelessly in the space vacuum (The Soroban Host environment) — is designed to handle a limited number of commands (CPU instructions) per mission (transaction, initiation of a new guest environment). To successfully navigate your mission, you need to keep a close eye on your engine's capacity and the energy costs involved.

Mission briefing:

  • Instruction limits: Your spaceship can process up to 100 million commands per mission. Beyond that, you risk overloading the engine room and failing your mission.
  • Energy costs: Each packet of 10,000 commands costs 25 STROOPs (the smallest unit of XLM, the Stellar galaxy's native fuel). The more commands you issue, the more STROOPs you burn.
  • Efficient navigation: Just like a skilled space captain, you'll need to optimize your journey. For example, if your mission requires 50,000 commands, you'll consume 5 energy packets, costing you 125 STROOPs.

Formula:

Total cost (in STROOPs) = Ceil(Div(No of Commands × 25, 10000))

Javascript Snippet:

function computeInstructionFee(instructions) {
    const FEE_RATE = 25;
    const DIVISOR = 10000;
    const instructionsNum = Number(instructions);
    const fee = (instructionsNum * FEE_RATE) / DIVISOR;
    return Math.ceil(fee);
}

console.log(computeInstructionFee(5000));  // Output: 13
Enter fullscreen mode Exit fullscreen mode

Pit stop to understand a few basic concepts

Ledgers and ledger Entries

The Ledger is like a cosmic photograph, capturing the state of every star, planet, and space station in our Stellar galaxy. It's a universal record that all the stars (nodes) agree upon, showing the position and status of every celestial body. Check more about it here

Ledger entries are like the individual celestial bodies in our cosmic photograph. They're the building blocks that make up the entire ledger.
the Stellar ledger contains various types of entries:

Accounts: These are like the inhabited planets, each with its own balance of xlm (lumens) and other cosmic resources.
Claimable balances: Think of these as treasure chests floating in space, waiting for the right explorer to claim them.
Liquidity pools: Think of these as cosmic trading posts where different types of stellar resources can be exchanged.
Contract data: These are like space stations running advanced AI systems (smart contracts), storing crucial data for various intergalactic operations.

Each Ledger entry contains specific information about that celestial object, just as a planetary database would store details about a planet's atmosphere, inhabitants, and resources.

Read and write ledger entry

Each read of a ledger entry(cosmic scan) costs 6,250 STROOPs.
Your spaceship can perform up to 40 scans per mission (transaction).

Each ledger entry written (galactic inscription) costs 10,000 STROOPs.
Your spaceship can make up to 25 inscriptions per mission(transaction).
Each inscription also counts as a scan for fee purposes.

Formula:
Total Cost (in STROOPs) = (Number of Scans × 6,250) + (Number of Inscriptions × 10,000)

Remember, space cadet: Every inscription (write) is also counted as a scan (read)!

Maximum cosmic data operations:

  • Max scans per mission: 40
  • Max inscriptions per mission: 25

Javascript Snippet:

function computeReadEntriesFee(numberOfReadsandWriteEntries) {
    const FEE_RATE = 6250;
    const numberOfReadsandWriteEntriesNum = Number(numberOfReadsandWriteEntries);
    const fee = (numberOfReadsandWriteEntriesNum * FEE_RATE);
    return fee;
}

function computeWriteEntriesFee(numberOfWriteEntries) {
    const FEE_RATE = 10000;
    const numberOfWriteEntriesNum = Number(numberOfWriteEntries);
    const fee = numberOfWriteEntriesNum * FEE_RATE;
    return fee;
}

console.log(computeReadEntriesFee(5)); // Output: 31250 
console.log(computeWriteEntriesFee(5)); // Output: 50000
Enter fullscreen mode Exit fullscreen mode

Read and write bytes

The data that is being and read and written when a soroban contract is invoked using a transaction:

All the data that is read

  1. Contract WASM Code: The contract's instructions, like a spaceship's blueprint.
  2. Contract instance data: The current state of the space station (if any).
  3. Pilot's log (Account Info): Details of the account initiating the mission.
  4. Galactic archives (Contract Storage): Any previous relevant persistent or temporary data.

All the data written

  1. Updated space station status: Changes to the contract's state.
  2. Pilot's log update: Adjusting the account's sequence number and balance.
  3. New Stellar objects: Creation of new accounts, treasures, or space stations.
  4. Mission report: Recording the results of the transaction.

Limits and costs:

Galactic read capacity (Read data):

Your spaceship can read up to 200 KB of data per mission.
Cost: 1,786 STROOPs per 1 KB read.

Stellar write capacity (Write data):

Your spaceship can write up to 65 KB of data per mission.
Cost: Approximately 11,800 STROOPs per 1 KB written.

Formula:
Total cost = (KB Read × 1,786) + (KB Written × 11,800)
Example mission:
If your spaceship reads 50 KB and writes 10 KB:
Cost = (50 × 1,786) + (10 × 11,800) = 89,300 + 118,000 = 207,300 STROOPs

Remember, space navigator: Writing data to the cosmic ledger is significantly more expensive than reading.

Javascript Snippet:

function computeReadBytesFee(bytesRead) {
    const FEE_RATE = 1786;
    const DIVISOR = 1024;
    const bytesReadNum = Number(bytesRead);
    const fee = (bytesReadNum * FEE_RATE) / DIVISOR;
    return Math.ceil(fee);
}

function computeWriteBytesFee(bytesWritten) {
// Approx 11800 STROOPs, refer this for more information
// https://developers.stellar.org/docs/learn/fundamentals/fees-resource-limits-metering#dynamic-pricing-for-storage
    const FEE_RATE = 11800;  
    const DIVISOR = 1024;
    const bytesWrittenNum = Number(bytesWritten);
    const fee = (bytesWrittenNum * FEE_RATE) / DIVISOR;
    return Math.ceil(fee);
}

console.log(computeReadBytesFee(5000));  // Output: 8721
console.log(computeWriteBytesFee(5000));  // Output: 57618
Enter fullscreen mode Exit fullscreen mode

Rent component of the resource fee

Cosmic real estate: Understanding state archival in the Soroban Galaxy

In the vast Soroban galaxy, data is like cosmic real estate. Just as planets need maintenance to remain habitable, data in Soroban needs regular upkeep to stay accessible. This is where the concept of state archival comes into play.

Time to live (TTL):
Imagine each piece of data as a star with a limited lifespan. This lifespan is called Time To Live (TTL). If a star's TTL reaches zero, it goes supernova - either disappearing entirely or becoming temporarily inaccessible.

Storage types:
There are three types of cosmic storage in Soroban:

a) Temporary storage: These are like shooting stars. They're the cheapest to maintain but vanish forever when their TTL reaches zero. Perfect for fleeting data that doesn't need to stick around.
b) Persistent storage: Think of these as planetoids. More expensive to maintain, but when their TTL reaches zero, they don't disappear - they just become temporarily inaccessible. They can be 'restored' later.
c) Instance storage: These are like the core of a space station. They're tied to the lifespan of your contract instance and are always accessible as long as your contract is alive. They are just like persistent storage in their workings but their TTL is linked to the contract’s instance TTL.

Rent and TTL extension:
To keep your cosmic data alive and accessible, you need to pay rent. This rent comes in the form of extending the TTL of your data. It's like refueling your cosmic bodies to keep them shining.

For Temporary and Persistent storage, each piece of data has its own TTL that needs individual extension. For Instance storage, extending the TTL of the contract instance automatically extends all associated data.

Javascript Snippet:



const TTL_ENTRY_SIZE = 48; 
const DATA_SIZE_1KB_INCREMENT = 1024;

/**
 * Calculate the rent fee for a given size and number of ledgers
 * @param {boolean} isPersistent - Whether the ledger entry is persistent
 * @param {number} entrySize - Size of the entry in bytes
 * @param {number} rentLedgers - Number of ledgers to rent for
 * @returns {bigint} Calculated rent fee
 */
function rentFeeForSizeAndLedgers(isPersistent, entrySize, rentLedgers) {

    // Use BigInt for all calculations to avoid overflow
    const num = BigInt(entrySize) *
                BigInt(11800) *
                BigInt(rentLedgers);
    // Reference: https://github.com/stellar/stellar-core/blob/master/soroban-settings/testnet_settings.json

    const storageCoef = isPersistent
        ? BigInt(2103)
        : BigInt(4206);

    const DIVISOR = BigInt(1024) * storageCoef;

    const fee = ceilN(num , DIVISOR);

    return fee
}

/**
 * Calculate the size of a half-open range (lo, hi], or null if lo > hi
 * @param {number} lo - Lower bound (exclusive)
 * @param {number} hi - Upper bound (inclusive)
 * @returns {number|null} Size of the range, or null if invalid
 */
function exclusiveLedgerDiff(lo, hi) {
    const diff = hi - lo;
    return diff > 0 ? diff : null;
}

/**
 * Calculate the size of a closed range [lo, hi], or null if lo > hi
 * @param {number} lo - Lower bound (inclusive)
 * @param {number} hi - Upper bound (inclusive)
 * @returns {number|null} Size of the range, or null if invalid
 */
function inclusiveLedgerDiff(lo, hi) {
    const diff = exclusiveLedgerDiff(lo, hi);
    return diff !== null ? diff + 1 : null;
}

const ceilN = (n, d) => n / d + (n % d ? 1n : 0n)

/**
 * Calculate the rent fee for a single entry change
 * @param {LedgerEntryRentChange} entryChange - The entry change
 * @param {number} currentLedger - Current ledger sequence
 * @returns {bigint} Calculated rent fee
 */
function rentFeePerEntryChange(entryChange, currentLedger) {
    let fee = 0n;

    // If there was a difference-in-expiration, pay for the new ledger range
    // at the new size.
    const rentLedgers = entryChange.extensionLedgers(currentLedger);
    if (rentLedgers !== null) {
        fee += rentFeeForSizeAndLedgers(
            entryChange.isPersistent,
            entryChange.newSizeBytes,
            rentLedgers
        );
    }

    // If there were some ledgers already paid for at an old size, and the size
    // of the entry increased, those pre-paid ledgers need to pay top-up fees to
    // account for the change in size.
    const prepaidLedgers = entryChange.prepaidLedgers(currentLedger);
    const sizeIncrease = entryChange.sizeIncrease();

    if (prepaidLedgers !== null && sizeIncrease !== null) {
        fee += rentFeeForSizeAndLedgers(
            entryChange.isPersistent,
            sizeIncrease,
            prepaidLedgers
        );
    }

    return fee;
}

// Main Rent Calculation Function
function computeRentFee(changedEntries, currentLedgerSeq) {
    let fee = 0n;
    let extendedEntries = 0n;
    let extendedEntryKeySizeBytes = 0;

    for (const e of changedEntries) {
        fee += rentFeePerEntryChange(e, currentLedgerSeq);
        if (e.oldLiveUntilLedger < e.newLiveUntilLedger) {
            extendedEntries += 1n;
            extendedEntryKeySizeBytes += TTL_ENTRY_SIZE;
        }
    }

    fee += BigInt(10000) * extendedEntries;
    fee = fee + ceilN(BigInt(extendedEntryKeySizeBytes) * BigInt(11800), BigInt(1024) )            
    return fee;
}

const feeConfig = new RentFeeConfiguration(
    11800,  // feePerWrite1kb (Approx 11,800 STROOPs per 1KB written)
    10000,  // feePerWriteEntry (10,000  per entry written)
    2103,   // persistentRentRateDenominator
    4206    // temporaryRentRateDenominator
);

// Example Inputs
// Create an array of LedgerEntryRentChange instances
const changedEntries = [
    // A new persistent entry
    new LedgerEntryRentChange(
        true,   // isPersistent
        0,      // oldSizeBytes (0 for new entries)
        100,    // newSizeBytes
        0,      // oldLiveUntilLedger (0 for new entries)
        1000    // newLiveUntilLedger
    ),
    // An existing persistent entry with increased size and extended TTL
    new LedgerEntryRentChange(
        true,   // isPersistent
        50,     // oldSizeBytes
        150,    // newSizeBytes
        500,    // oldLiveUntilLedger
        1500    // newLiveUntilLedger
    ),
    // A temporary entry with extended TTL but no size change
    new LedgerEntryRentChange(
        false,  // isPersistent (temporary)
        200,    // oldSizeBytes
        200,    // newSizeBytes (no change)
        800,    // oldLiveUntilLedger
        1200    // newLiveUntilLedger
    )
];

// Set the current ledger sequence
const currentLedgerSeq = 400;
// Calculate the rent fee
const totalRentFee = computeRentFee(changedEntries, currentLedgerSeq);
console.log(`Total Rent Fee: ${totalRentFee} STROOPs`); // Output: 33088
Enter fullscreen mode Exit fullscreen mode

Bandwidth and historical fee components

In the Soroban galaxy, sending and storing transaction data incurs two types of stellar fees:

  1. Interstellar bandwidth fee: Think of this as the cost of beaming your transaction across the cosmic network. Cost: 1,624 STROOPs per 1KB of transaction size Purpose: Ensures smooth traffic in the galactic roadways and prevents network congestion.
  2. Galactic archives fee: This fee covers the cost of etching your transaction into the eternal cosmic ledger. Cost: 16,235 STROOPs per 1KB of transaction size Purpose: Maintains the vast libraries of Soroban's transaction history for future reference.

Total fee:
For every 1KB of your transaction's size, you'll pay 17,859 STROOPs (1,624 + 16,235) for safe passage and proper recording in the Soroban cosmos.

Remember, space navigator: The size of your cosmic message matters! Optimize your transmissions to ensure efficient and economical galactic transactions!

Javascript Snippet:

function computeHistoricalFee(sizeOfTheTxEnvelopeInBytes) {
    const FEE_RATE = 16235;
    const DIVISOR = 1024;
    // Base Size of the Transaction Result, irrespective of the 
    const baseSizeOfTheTxnResultInBytes = 300
    const effectiveTxnSize = Number(sizeOfTheTxEnvelopeInBytes) + Number(baseSizeOfTheTxnResultInBytes);
    const fee = (effectiveTxnSize * FEE_RATE) / DIVISOR;
    return Math.ceil(fee);
}

function computeBandwidthFee(sizeOfTheTxEnvelopeInBytes) {
    const FEE_RATE = 1624;
    const DIVISOR = 1024;
    const effectiveTxnSize = Number(sizeOfTheTxEnvelopeInBytes);
    const fee = (effectiveTxnSize * FEE_RATE) / DIVISOR;
    return Math.ceil(fee);
}

console.log(computeHistoricalFee(10240));  // Output: 167107
console.log(computeBandwidthFee(10240));  // Output: 16240
Enter fullscreen mode Exit fullscreen mode

Events and return value

In the Soroban galaxy, smart contracts transactions don't just execute silently - they send out cosmic echoes in the form of events and return values.

  1. Cosmic Echoes (Events):
    These are signals sent out by contracts, broadcasting important occurrences to the wider galaxy. Off-chain applications can tune in to these echoes to stay informed about on-chain activities.

  2. Stellar Responses (Return Values):
    When you invoke a contract function, it doesn't just act - it responds. The return value is the direct answer from your cosmic call to the contract.

Galactic limitations:

  • Combined size limit: Your events and return value can occupy up to 8 KB of cosmic space.
  • Resource cost: For every 1 KB of these cosmic signals, you'll spend 10,000 STROOPs.

Cosmic refundable fees:
Before your transaction embarks on its journey, the Soroban network collects a refundable fee from your galactic account. This fee is based on your estimated event and return value size. After the cosmic voyage (transaction execution) is complete:

  1. If you used less space than estimated, the excess STROOPs is returned to your account.
  2. If you used exactly what you estimated, no further action is needed.
  3. However, if your cosmic echoes exceed your initial estimate, your entire mission will be aborted! Ensure you allocate enough fuel (fees) for your journey.

Example:

Let's say your contract function emits events and returns a value, totaling 3 KB of data:

Cosmic Echo Cost:
3 KB * 10,000 STROOPs/KB = 30,000 STROOPs = 0.003 XLM

Remember, space navigator: While these cosmic echoes are crucial for intergalactic (off-chain) communication, they come at a cost. Keep your signals concise to conserve your STROOPs!

Javascript Snippet:

function computeEventsOrReturnValueFee(sizeOfTheEventsOrReturnValueInBytes) {
    const FEE_RATE = 10000;
    const DIVISOR = 1024;
    const sizeOfTheEventsOrReturnValueInBytesNum = Number(sizeOfTheEventsOrReturnValueInBytes);
    const fee = (sizeOfTheEventsOrReturnValueInBytesNum * FEE_RATE) / DIVISOR;
    return Math.ceil(fee);
}

console.log(computeEventsOrReturnValueFee(10240)); // Output: 100000
Enter fullscreen mode Exit fullscreen mode

Inclusion fees: your cosmic express lane toll

In the vast Soroban galaxy, not all cosmic journeys are created equal. Sometimes, the space lanes get congested, and that's where Inclusion Fees come into play. Think of these as your express lane tolls, ensuring your mission (transaction) gets priority boarding on the next available intergalactic transport (ledger).

Cosmic toll mechanics

The inclusion fee is like bidding for a first-class ticket on a space shuttle. It's the maximum amount of stellar dust (XLM) you're willing to pay for your mission to be included in the next cosmic leap (ledger). Here's how it works:

  • Cosmic calculation: Inclusion Fee = Number of Space Maneuvers (Operations) * Galactic Base Fee
  • Minimum toll: There's a minimum toll set by the Intergalactic Council (SDF), currently 100 STROOPs per maneuver. (Remember: 1 STROOPs = 0.0000001 XLM)
  • Smart contract missions: These special missions are limited to one maneuver per journey (excluding refueling stops, aka fee-bump transactions).
  • Dynamic pricing: During rush hour (high network congestion), the galaxy enters "wormhole pricing" mode(surge pricing mode), potentially increasing your toll. Know more about it here

Consulting the galactic fee oracle

Before embarking on your journey, it's wise to consult the Galactic Fee Oracle (Soroban RPC) to gauge current toll rates. Here's how to communicate with the oracle using your ship's interface capabilities (JavaScript):

const StellarSDK = require('stellar-sdk');
const server = new StellarSDK.SorobanRpc.Server('https://soroban-testnet.stellar.org:443');

async function getFeeStats() {
  try {
    const feeStats = await server.getFeeStats();
    console.log("Fee Stats:", JSON.stringify(feeStats, null, 2));
    return feeStats.sorobanInclusionFee;
  } catch (error) {
    console.error('Error fetching fee stats:', error);
  }
}

// Usage
getFeeStats().then(inclusionFees => {
  if (inclusionFees) {
    console.log("Current Soroban Inclusion Fees:", inclusionFees);
    console.log("Recommended fee (90th percentile):", inclusionFees.p90);
  }
});
Enter fullscreen mode Exit fullscreen mode

Understanding the fee stats output

The getFeeStats() method returns an object with various fee statistics:

{
  "sorobanInclusionFee": {
    "max": "210",
    "min": "100",
    "mode": "100",
    "p10": "100",
    "p20": "100",
    "p30": "100",
    "p40": "100",
    "p50": "100",
    "p60": "100",
    "p70": "100",
    "p80": "100",
    "p90": "120",
    "p95": "190",
    "p99": "200",
    "transactionCount": "10",
    "ledgerCount": 50
  }
}
Enter fullscreen mode Exit fullscreen mode

Decoding the cosmic toll report

The Galactic Fee Oracle's response is a complex star chart of toll information. Here's how to read this celestial map:

  • max: The highest toll paid by recent space travelers.
  • min: The lowest toll that got someone through the express lane.
  • mode: The most common toll paid by fellow cosmic explorers.
  • p10, p20, ..., p99: Celestial percentiles of tolls paid. For instance, p90 means 90% of starships paid this toll or less for their journey.
  • transactionCount: The number of recent space voyages considered in this star chart.
  • ledgerCount: The number of recent cosmic leaps (ledgers) analyzed for this data.

Choosing Your warp speed (Toll level)

Based on these cosmic readings, you can choose the right toll for your mission:

  1. For leisurely space cruises: Consider the mode or p50 (median) toll.
  2. For important diplomatic missions: The p90 or p95 toll usually ensures swift passage through the cosmos.
  3. For critical, time-sensitive operations: The p99 toll gives you the best chance of immediate wormhole access.

Conclusion

This tutorial explained Soroban fee estimation, covering Resource and Inclusion Fees. Understanding these components is crucial for developing efficient, cost-effective smart contracts on Soroban. Proper fee management optimizes contract performance and improves user experience in the Stellar ecosystem.

All scripts stacked together in a single file for convenience:

function computeInstructionFee(instructions) {
    const FEE_RATE = 6250;
    const DIVISOR = 10000;
    const instructionsNum = Number(instructions);
    const fee = (instructionsNum * FEE_RATE) / DIVISOR;
    return Math.ceil(fee);
}

function computeReadEntriesFee(numberOfReadsandWriteEntries) {
    const FEE_RATE = 6250;
    const numberOfReadsandWriteEntriesNum = Number(numberOfReadsandWriteEntries);
    const fee = (numberOfReadsandWriteEntriesNum * FEE_RATE);
    return fee;
}

function computeWriteEntriesFee(numberOfWriteEntries) {
    const FEE_RATE = 10000;
    const numberOfWriteEntriesNum = Number(numberOfWriteEntries);
    const fee = numberOfWriteEntriesNum * FEE_RATE;
    return fee;
}

// console.log(computeReadEntriesFee(5)); // Output: 31250 
// console.log(computeWriteEntriesFee(5)); // Output: 50000

function computeReadBytesFee(bytesRead) {
    const FEE_RATE = 1786;
    const DIVISOR = 1024;
    const bytesReadNum = Number(bytesRead);
    const fee = (bytesReadNum * FEE_RATE) / DIVISOR;
    return Math.ceil(fee);
}

function computeWriteBytesFee(instructions) {
 // Approx 11800 STROOPs, refer this for more information
 // https://developers.stellar.org/docs/learn/fundamentals/fees-resource-limits-metering#dynamic-pricing-for-storage
    const FEE_RATE = 11800;
    const DIVISOR = 1024;
    const instructionsNum = Number(instructions);
    const fee = (instructionsNum * FEE_RATE) / DIVISOR;
    return Math.ceil(fee);
}

// console.log(computeReadBytesFee(51200));  // Output: 8721
// console.log(computeWriteBytesFee(10240));  // Output: 57618

function computeHistoricalFee(sizeOfTheTxEnvelopeInBytes) {
    const FEE_RATE = 16235;
    const DIVISOR = 1024;
    const baseSizeOfTheTxnResultInBytes = 300
    const effectiveTxnSize = Number(sizeOfTheTxEnvelopeInBytes) + Number(baseSizeOfTheTxnResultInBytes);
    const fee = (effectiveTxnSize * FEE_RATE) / DIVISOR;
    return Math.ceil(fee);
}

function computeBandwidthFee(sizeOfTheTxEnvelopeInBytes) {
    const FEE_RATE = 1624;
    const DIVISOR = 1024;
    const effectiveTxnSize = Number(sizeOfTheTxEnvelopeInBytes);
    const fee = (effectiveTxnSize * FEE_RATE) / DIVISOR;
    return Math.ceil(fee);
}

// console.log(computeHistoricalFee(10240));  // Output: 167107
// console.log(computeBandwidthFee(10240));  // Output: 16240

function computeEventsOrReturnValueFee(sizeOfTheEventsOrReturnValueInBytes) {
    const FEE_RATE = 10000;
    const DIVISOR = 1024;
    const sizeOfTheEventsOrReturnValueInBytesNum = Number(sizeOfTheEventsOrReturnValueInBytes);
    const fee = (sizeOfTheEventsOrReturnValueInBytesNum * FEE_RATE) / DIVISOR;
    return Math.ceil(fee);
}

// console.log(computeEventsOrReturnValueFee(10240)); // Output: 100000

class LedgerEntryRentChange {
    constructor(isPersistent, oldSizeBytes, newSizeBytes, oldLiveUntilLedger, newLiveUntilLedger) {
        // Whether this is persistent or temporary entry.
        this.isPersistent = isPersistent;

        // Size of the entry in bytes before it has been modified, including the key.
        // 0 for newly-created entries.
        this.oldSizeBytes = oldSizeBytes;

        // Size of the entry in bytes after it has been modified, including the key.
        this.newSizeBytes = newSizeBytes;

        // Live until ledger of the entry before it has been modified.
        // Should be less than the current ledger for newly-created entries.
        this.oldLiveUntilLedger = oldLiveUntilLedger;

        // Live until ledger of the entry after it has been modified.
        this.newLiveUntilLedger = newLiveUntilLedger;
    }

    entryIsNew() {
        return this.oldSizeBytes === 0 && this.oldLiveUntilLedger === 0;
    }

    extensionLedgers(currentLedger) {
        const ledgerBeforeExtension = this.entryIsNew() 
            ? Math.max(currentLedger - 1, 0)
            : this.oldLiveUntilLedger;
        return exclusiveLedgerDiff(ledgerBeforeExtension, this.newLiveUntilLedger);
    }

    prepaidLedgers(currentLedger) {
        if (this.entryIsNew()) {
            return null;
        } else {
            return inclusiveLedgerDiff(currentLedger, this.oldLiveUntilLedger);
        }
    }

    sizeIncrease() {
        const increase = this.newSizeBytes - this.oldSizeBytes;
        return increase > 0 ? increase : null;
    }
}

class RentFeeConfiguration {

    constructor(
        feePerWrite1kb = 0,
        feePerWriteEntry = 0,
        persistentRentRateDenominator = 0,
        temporaryRentRateDenominator = 0
    ) {
        // Fee per 1KB written to ledger.
        this.feePerWrite1kb = feePerWrite1kb;

        // Fee per 1 entry written to ledger.
        this.feePerWriteEntry = feePerWriteEntry;

        // Denominator for the total rent fee for persistent storage.
        this.persistentRentRateDenominator = persistentRentRateDenominator;

        // Denominator for the total rent fee for temporary storage
        this.temporaryRentRateDenominator = temporaryRentRateDenominator;
    }  
}

const TTL_ENTRY_SIZE = 48; 
const DATA_SIZE_1KB_INCREMENT = 1024;

/**
 * Calculate the rent fee for a given size and number of ledgers
 * @param {boolean} isPersistent - Whether the ledger entry is persistent
 * @param {number} entrySize - Size of the entry in bytes
 * @param {number} rentLedgers - Number of ledgers to rent for
 * @returns {bigint} Calculated rent fee
 */
function rentFeeForSizeAndLedgers(isPersistent, entrySize, rentLedgers) {

    // Use BigInt for all calculations to avoid overflow
    const num = BigInt(entrySize) *
                BigInt(11800) *
                BigInt(rentLedgers);

    const storageCoef = isPersistent
        ? BigInt(2103)
        : BigInt(4206);

    const DIVISOR = BigInt(1024) * storageCoef;

    const fee = ceilN(num , DIVISOR);

    return fee
}

/**
 * Calculate the size of a half-open range (lo, hi], or null if lo > hi
 * @param {number} lo - Lower bound (exclusive)
 * @param {number} hi - Upper bound (inclusive)
 * @returns {number|null} Size of the range, or null if invalid
 */
function exclusiveLedgerDiff(lo, hi) {
    const diff = hi - lo;
    return diff > 0 ? diff : null;
}

/**
 * Calculate the size of a closed range [lo, hi], or null if lo > hi
 * @param {number} lo - Lower bound (inclusive)
 * @param {number} hi - Upper bound (inclusive)
 * @returns {number|null} Size of the range, or null if invalid
 */
function inclusiveLedgerDiff(lo, hi) {
    const diff = exclusiveLedgerDiff(lo, hi);
    return diff !== null ? diff + 1 : null;
}

const ceilN = (n, d) => n / d + (n % d ? 1n : 0n)

/**
 * Calculate the rent fee for a single entry change
 * @param {LedgerEntryRentChange} entryChange - The entry change
 * @param {number} currentLedger - Current ledger sequence
 * @returns {bigint} Calculated rent fee
 */
function rentFeePerEntryChange(entryChange, currentLedger) {
    let fee = 0n;

    // If there was a difference-in-expiration, pay for the new ledger range
    // at the new size.
    const rentLedgers = entryChange.extensionLedgers(currentLedger);
    if (rentLedgers !== null) {
        fee += rentFeeForSizeAndLedgers(
            entryChange.isPersistent,
            entryChange.newSizeBytes,
            rentLedgers
        );
    }

    // If there were some ledgers already paid for at an old size, and the size
    // of the entry increased, those pre-paid ledgers need to pay top-up fees to
    // account for the change in size.
    const prepaidLedgers = entryChange.prepaidLedgers(currentLedger);
    const sizeIncrease = entryChange.sizeIncrease();

    if (prepaidLedgers !== null && sizeIncrease !== null) {
        fee += rentFeeForSizeAndLedgers(
            entryChange.isPersistent,
            sizeIncrease,
            prepaidLedgers
        );
    }

    return fee;
}

function computeRentFee(changedEntries, currentLedgerSeq) {
    let fee = 0n;
    let extendedEntries = 0n;
    let extendedEntryKeySizeBytes = 0;

    for (const e of changedEntries) {
        fee += rentFeePerEntryChange(e, currentLedgerSeq);
        if (e.oldLiveUntilLedger < e.newLiveUntilLedger) {
            extendedEntries += 1n;
            extendedEntryKeySizeBytes += TTL_ENTRY_SIZE;
        }
    }

    fee += BigInt(10000) * extendedEntries;
    //(Math.ceil((extendedEntryKeySizeBytes * 11800 ) / 1024))
    fee = fee + ceilN(BigInt(extendedEntryKeySizeBytes) * BigInt(11800), BigInt(1024) )            
    return fee;
}

const feeConfig = new RentFeeConfiguration(
    11800,  // feePerWrite1kb (Approx 11,800 STROOPs per 1KB written)
    10000,  // feePerWriteEntry (10,000 STROOPs per entry written)
    2103,   // persistentRentRateDenominator
    4206    // temporaryRentRateDenominator
);

// Create an array of LedgerEntryRentChange instances
const changedEntries = [
    // A new persistent entry
    new LedgerEntryRentChange(
        true,   // isPersistent
        0,      // oldSizeBytes (0 for new entries)
        100,    // newSizeBytes
        0,      // oldLiveUntilLedger (0 for new entries)
        1000    // newLiveUntilLedger
    ),
    // An existing persistent entry with increased size and extended TTL
    new LedgerEntryRentChange(
        true,   // isPersistent
        50,     // oldSizeBytes
        150,    // newSizeBytes
        500,    // oldLiveUntilLedger
        1500    // newLiveUntilLedger
    ),
    // A temporary entry with extended TTL but no size change
    new LedgerEntryRentChange(
        false,  // isPersistent (temporary)
        200,    // oldSizeBytes
        200,    // newSizeBytes (no change)
        800,    // oldLiveUntilLedger
        1200    // newLiveUntilLedger
    )
];

// Set the current ledger sequence
const currentLedgerSeq = 400;
// Calculate the rent fee
const totalRentFee = computeRentFee(changedEntries, currentLedgerSeq);
console.log(`Total Rent Fee: ${totalRentFee} STROOPs`);

const StellarSDK = require('stellar-sdk');
const server = new StellarSDK.SorobanRpc.Server('https://soroban-testnet.stellar.org:443');

async function getFeeStats() {
  try {
    const feeStats = await server.getFeeStats();
    console.log("Fee Stats:", JSON.stringify(feeStats, null, 2));
    return feeStats.sorobanInclusionFee;
  } catch (error) {
    console.error('Error fetching fee stats:', error);
  }
}

// Usage
getFeeStats().then(inclusionFees => {
  if (inclusionFees) {
    console.log("Current Soroban Inclusion Fees:", inclusionFees);
    console.log("Recommended fee (90th percentile):", inclusionFees.p90);
  }
});
Enter fullscreen mode Exit fullscreen mode

What I Created

Everybody says stellar fees are low, but more than often people don't have the idea what factors affect a transaction's overall fee. I have done a deep dive on Resource fees and inclusion fees, which are the two major components of a transaction fee, and explained the intricacies involved in their calculations. To make this dry and boring technical topic, into a fun-filled affair, I have used space metaphors to explain it in a engaging way.

Journey

I have been reading docs here, and trying to get to the heart of the fee calculation topic. I have been working on Stellar projects for the past 1.5 years now, but I can still sometimes struggle with reading deeply technical topics regarding the Stellar ecosystem. For a beginner in this space, to understand how fees are affected by various factors, is a very daunting task. So as to make this topic beginner-friendly, I have tried to write the tutorial in a metaphorical way using space-theme analogies so that even a beginner enjoys reading it. I feel documentation should be more engaging and intuitive, so people get the hang of Stellar pretty fast.

Also I wanted to really dig deep into how rent is calculated for ledger entries, so I had to research the rs-soroban-env and soroban-settings to check for clues, so that whenever I do actually calculate rents while working on stellar projects, I am doing it as accurately as possible. I think I will be building more Stellar projects and remain committed to the ideas and vision of Stellar for many years to come.

Top comments (4)

Collapse
 
realcodycordova profile image
Cody Cordova

Thank you for creating this tutorial 🎉❤️

Collapse
 
rahul_navgire_b1e795d824f profile image
Rahul Navgire

This is very helpful

Collapse
 
desmo profile image
Erick Fernandez

great job, very insightful

Collapse
 
shrey_d0c66e4d90d0fdb5467 profile image
Shrey

Very Helpful