New: AI Agent SDK is live — integrate Claude-powered wallet actions into your app. Read the docs
GuidesFee & Gas Budget Model

Fee & Gas Budget Model

Soroban uses a sophisticated fee model based on resource consumption rather than simple per-transaction charges. This guide explains how fees work and how Veil handles them.

Overview

Soroban fees consist of three components:

  1. Base Fee — Fixed cost per transaction (network inflation protection)
  2. Resource Fee — Computed from CPU/memory/network usage
  3. Refundable Fee — Reserved but returned if actual usage is lower
Total Fee = Base Fee + Resource Fee
Charged Amount = Base Fee + (Resource Fee + Refundable Fee Buffer)
Refund = Refundable Fee - (Actual Usage - Estimated Usage)

Fee Components Explained

Base Fee

The base fee is a fixed amount charged per transaction regardless of complexity.

  • Current: ~100 stroops per transaction (on Testnet/Futurenet)
  • Purpose: Prevents spam and funds network infrastructure
  • Deducted: From the fee-payer account immediately
  • Not refundable: Always charged and never returned

Resource Fee

Based on actual resource consumption:

  • CPU Instructions — Number of WASM instructions executed
  • Memory Bytes — Peak memory allocation during execution
  • Network I/O — Bytes transmitted to/from validators
  • Ledger I/O — Storage operations (read, write, modify)
  • Events — Size of emitted contract events

Each resource has a pricing schedule set by validators. Veil estimates resource usage and includes a safety margin.

Pricing Formula:

Resource Fee = 
  (cpu_instructions / cpu_scaling_factor) +
  (memory_bytes / memory_scaling_factor) +
  (network_bytes / network_scaling_factor) +
  (ledger_operations * operation_cost) +
  (event_bytes / event_scaling_factor)

Refundable Fee

A buffer reserved during transaction submission that exceeds the estimated resource fee.

  • Purpose: Accounts for estimation inaccuracy
  • Amount: Usually 50-200% of estimated resource fee
  • When Used: Only if actual usage exceeds estimate
  • When Refunded: If actual usage is below estimate

Example:

Estimated Resource Fee: 1000 stroops
Refundable Fee Buffer: 500 stroops (50% safety margin)

Submitted Fee: 1000 + 500 = 1500 stroops

After Execution:
- Actual Resource Fee Used: 800 stroops
- Refund: 500 + (1000 - 800) = 700 stroops
- Net Cost: 800 stroops

Worked Example: Deploy → Invoke → Measure → Derive Fee

This example shows how fees are calculated in practice using real numbers.

Step 1: Deploy Contract

Scenario: Deploy Veil wallet contract to Testnet

Transaction Details:

Operation: ContractDeploy (WASM upload)
WASM Size: 45 KB
CPU Instructions: 2,500,000
Memory Peak: 512 KB
Network Bytes: 46,000 (45 KB WASM + overhead)

Fee Estimation:

Base Fee:                    100 stroops
CPU Fee:      2,500,000 / 20,000 =  125 stroops
Memory Fee:   512,000 / 8,000 =     64 stroops
Network Fee:  46,000 / 100 =       460 stroops
Ledger Fee:   1 write × 200 =      200 stroops
Event Fee:    0 stroops
────────────────────────────────────
Estimated Resource Fee:             849 stroops
Refundable Buffer (100%):          849 stroops
────────────────────────────────────
Total Submitted:                  1,798 stroops

Actual Execution:

Base Fee (charged):           100 stroops
Actual CPU Used:     2,400,000 / 20,000 =  120 stroops
Actual Memory:       400,000 / 8,000 =      50 stroops
Actual Network:      45,800 / 100 =        458 stroops
Actual Ledger:       1 write × 200 =       200 stroops
────────────────────────────────────
Total Actual Cost:                 928 stroops

Refund Calculation:
- Estimated Resource: 849 stroops
- Actual Resource: 828 stroops
- Refundable Buffer: 849 stroops
- Unused Estimate Portion: 849 - 828 = 21 stroops
- Refund: 21 + 849 (buffer) = 870 stroops

Final Cost: 1,798 - 870 = 928 stroops ✓

Step 2: Initialize Wallet (Invoke)

Scenario: Call init(pubkey) to register a new wallet

Transaction Details:

Operation: InvokeHostFunction
Function: init
Arguments: 1 (65-byte public key)
CPU Instructions: 150,000
Memory Peak: 64 KB
Ledger Writes: 1 (store signer pubkey)
Events: 1 (WalletInitialized event, 128 bytes)

Fee Estimation:

Base Fee:                    100 stroops
CPU Fee:      150,000 / 20,000 =   7.5 stroops → 8
Memory Fee:   64,000 / 8,000 =      8 stroops
Ledger Fee:   1 write × 200 =     200 stroops
Event Fee:    128 / 100 =          1.3 stroops → 2
────────────────────────────────────
Estimated Resource Fee:             219 stroops
Refundable Buffer (75%):           164 stroops
────────────────────────────────────
Total Submitted:                    483 stroops

Actual Execution:

Base Fee (charged):            100 stroops
Actual Cost:                   210 stroops (slightly less than estimated)
────────────────────────────────────
Total Actual Cost:             310 stroops

Refund: 483 - 310 = 173 stroops ✓

Step 3: Execute Payment (Sign & Send)

Scenario: Send 100 XLM using WebAuthn signature

Transaction Details:

Operation: InvokeHostFunction
Function: send
Arguments: 3 (destination, amount, WebAuthn sig)
CPU Instructions: 450,000
Memory Peak: 256 KB
Ledger Reads: 1 (check signer)
Ledger Writes: 1 (update nonce)
Events: 1 (PaymentSent event, 256 bytes)
Native Operations: 1 (payment)

Fee Estimation:

Base Fee:                    100 stroops

Soroban Component:
CPU Fee:      450,000 / 20,000 =    22.5 stroops → 23
Memory Fee:   256,000 / 8,000 =     32 stroops
Ledger Read:  1 read × 100 =       100 stroops
Ledger Write: 1 write × 200 =      200 stroops
Event Fee:    256 / 100 =            2.6 stroops → 3
  ──────────────────────────────────
  Soroban Resource: 360 stroops

Native Operation (payment):
  Standard payment TX: 100 stroops
  ──────────────────────────────────
  Native Fee: 100 stroops

Refundable Buffer (80%): 360 × 0.80 = 288 stroops
────────────────────────────────────
Total Submitted: 100 + 360 + 100 + 288 = 848 stroops

Actual Execution:

Base Fee (charged):           100 stroops
Soroban Actual: 355 stroops (slightly less due to cache hits)
Native Fee:     100 stroops
────────────────────────────────────
Total Actual Cost: 555 stroops

Refund: 848 - 555 = 293 stroops ✓

How Veil Handles Fees

Fee Estimation Strategy

Veil uses a conservative estimation approach:

  1. Query current fee rates from Soroban RPC (getNetworkParams)

  2. Estimate resource usage based on operation type:

    • Known CPU/memory for standard operations
    • Historical data from similar transactions
    • Conservative multipliers for safety
  3. Add safety margins:

    • Resource Fee margin: 50-100% (accounts for state variance)
    • Refundable buffer: 75-100% (accounts for estimation error)
  4. Calculate total:

    totalFee = baseFee + estimatedResourceFee + refundableBuffer

Fee Reduction for Users

Veil optimizes fees where possible:

  • Batch operations to amortize base fee
  • Reuse contract state to reduce ledger reads
  • Cache public keys to reduce repeated lookups
  • Use deterministic nonces instead of atomic counters

Fee-Payer Derivation

Veil uses a separate fee-payer account (a standard Ed25519 G… keypair) from the user’s passkey-controlled wallet contract (C…). The user signs with their passkey to authorize a transaction; the fee-payer account is what actually pays the Stellar network fee, so a user never needs to think about fee balances.

⚠️

How that fee-payer key is derived is safety-relevant, not an implementation detail. Get the derivation wrong and you can either strand a wallet on an unfunded key it can never reproduce, or make the key recoverable by anyone who can read local storage. lib/feePayer.ts (single accessor, per ADR 0003) is the only place the rest of the app should read this key from.

Three derivation modes exist, tracked per-wallet in veil_feepayer_mode and pinned for life once established — a wallet’s fee-payer address must never change mid-life, or it’d be paying fees from an account that no longer holds the funds it was topped up with:

ModeHow the seed is producedWhere it’s storedUsed for
prf-rawRaw WebAuthn PRF output used directly as the Ed25519 seedsessionStorage onlyDefault for new wallets — matches what the mobile app does, so a wallet is recoverable on either client
prf-hkdfPRF output run through HKDF before usesessionStorage onlyWallets that were already pinned this way before prf-raw became the default — kept so their G… address doesn’t move
legacyHKDF(credentialId) — the non-secret WebAuthn credential IDlocalStorage (persisted)Pre-PRF wallets, and the fallback when an authenticator doesn’t support PRF

Why this matters, safety-wise: the credential ID used by legacy is not a secret — it’s returned in plaintext during every WebAuthn ceremony and stored un-encrypted as invisible_wallet_key_id. Anyone who can read that value can reconstruct the legacy fee-payer keypair with no biometric prompt at all. The PRF-based modes fix this because a PRF output can only be produced by the authenticator that holds the actual passkey — reading storage alone is not enough. PRF-derived seeds are therefore kept in sessionStorage only (gone on tab close / inactivity lock); only legacy ever touches localStorage. See ADR 0003 for the full security rationale and migration plan.

How the mode is chosen: on first establishment (ensureFeePayer()), the wallet derives every applicable candidate and — if no mode is already pinned — probes Horizon for which candidate’s address actually exists on-chain, picking the first funded one it finds. This beats guessing: guessing wrong would strand the user on an unfunded fee-payer with no obvious way to fix it. A wallet with no existing on-chain fee-payer defaults to prf-raw. If a PRF ceremony is attempted but fails or is unsupported, the wallet falls back to legacy automatically — no device is ever bricked by a missing PRF capability, but see the diagnostics note below.

Diagnostics for support/debugging: getFeePayerDiagnostics() records which candidates were tried, their on-chain probe results, and whether a PRF ceremony was attempted and why it failed if it did — rendered as plain text via formatFeePayerDiagnostics() so it can be pasted directly into a bug report. isFeePayerPrfDowngrade() flags the specific failure mode where a PRF ceremony was attempted but didn’t produce a usable key and the wallet fell back to legacy — such a wallet will not reproduce the same fee-payer address on a different, PRF-capable device, which is worth surfacing to the user rather than leaving silent.

Network-Aware Inclusion Fee

Separate from the resource fee above, every transaction also needs an inclusion-fee bid (stroops per operation) — how much you’re willing to pay to get into the ledger. lib/fees.ts’s inclusionFee() sets this bid differently per network:

export function inclusionFee(): string {
  return getNetwork().name === 'mainnet' ? '1000000' : BASE_FEE
}
  • Testnet bids the SDK’s minimum BASE_FEE (100 stroops).
  • Mainnet bids 1000000 stroops (0.1 XLM).

This isn’t arbitrary generosity: Mainnet surge-prices inclusion, especially on the Soroban lane, and a 100-stroop bid gets rejected outright with txINSUFFICIENT_FEE the moment there’s any real network load — which the mobile app hit on its first factory deploy and again on its first in-app transfer. Overbidding is safe on Stellar because the ledger only ever charges the effective market rate, not your bid — so a generous 0.1 XLM Mainnet bid costs approximately nothing in the common case while surviving a surge, whereas Testnet’s near-empty network never needs more than the minimum.

If you’re building your own transactions against this wallet’s contracts rather than going through the wallet UI, call inclusionFee() rather than hardcoding BASE_FEE — a fixed low bid that works fine on Testnet will fail intermittently on Mainnet under load.

Refundable Fee Mechanics

When Refunds Apply

Refunds occur when:

  • Actual resource usage < estimated usage
  • Contract state is cached (fewer reads)
  • Event emissions are optimized
  • CPU execution is faster than estimated

When Refunds Don’t Apply

  • Base fee is never refundable
  • Overages (actual > estimated) are charged in full
  • Network capacity issues may increase fees retroactively
  • Spam filters or priority fees may be added by validators

Checking Refunds

After transaction submission, check the result:

const result = await soroban.submitTransaction(tx)
console.log(`Submitted Fee: ${result.fees.submitted}`)
console.log(`Actual Fee: ${result.fees.actual}`)
console.log(`Refund: ${result.fees.submitted - result.fees.actual}`)

Refunds are automatic but not immediate. They appear in your account balance once the transaction is finalized (typically 5-30 seconds).

Fee Comparison: Soroban vs Traditional Payments

AspectSoroban ContractNative Payment
Base Fee100 stroops100 stroops
Resource FeeYes (usage-based)No
Refundable FeeYesNo
Typical Cost500-2000 stroops100-300 stroops
Why HigherComplex WASM executionSimple ledger update
When Worth ItSmart wallet featuresSimple transfers

Monitoring Fees

Use these tools to monitor and optimize fees:

1. RPC Network Parameters

const params = await soroban.getNetworkParams()
console.log(`Base Fee: ${params.baseFee}`)
console.log(`Fee Rate: ${params.feeRatePerKb}`)

2. Transaction Simulation

const simulation = await soroban.simulateTransaction(tx)
console.log(`Estimated Fee: ${simulation.fee}`)
console.log(`Resources Used: ${simulation.results[0].resources}`)

3. Ledger History

soroban contract invoke \
  --contract-id CAxxxx \
  --function send \
  --fee-budget 10000

Best Practices

  1. Estimate before submitting — Use simulation to get accurate fee predictions
  2. Use conservative multipliers — 1.5-2x estimates prevent failures
  3. Monitor network load — Fees spike during congestion; wait if possible
  4. Batch operations — Combine multiple calls to amortize base fee
  5. Cache state — Reuse data across transactions to reduce ledger I/O
  6. Keep fee-payer funded — Maintain 10+ XLM buffer in fee-payer account
⚠️

If a transaction runs out of budget mid-execution, it will fail and you’ll be charged for partial execution. Always include adequate refundable fee buffer.