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:
- Base Fee — Fixed cost per transaction (network inflation protection)
- Resource Fee — Computed from CPU/memory/network usage
- 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 stroopsWorked 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 stroopsActual 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 stroopsActual 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 stroopsActual 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:
-
Query current fee rates from Soroban RPC (
getNetworkParams) -
Estimate resource usage based on operation type:
- Known CPU/memory for standard operations
- Historical data from similar transactions
- Conservative multipliers for safety
-
Add safety margins:
- Resource Fee margin: 50-100% (accounts for state variance)
- Refundable buffer: 75-100% (accounts for estimation error)
-
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:
| Mode | How the seed is produced | Where it’s stored | Used for |
|---|---|---|---|
prf-raw | Raw WebAuthn PRF output used directly as the Ed25519 seed | sessionStorage only | Default for new wallets — matches what the mobile app does, so a wallet is recoverable on either client |
prf-hkdf | PRF output run through HKDF before use | sessionStorage only | Wallets that were already pinned this way before prf-raw became the default — kept so their G… address doesn’t move |
legacy | HKDF(credentialId) — the non-secret WebAuthn credential ID | localStorage (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
1000000stroops (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
| Aspect | Soroban Contract | Native Payment |
|---|---|---|
| Base Fee | 100 stroops | 100 stroops |
| Resource Fee | Yes (usage-based) | No |
| Refundable Fee | Yes | No |
| Typical Cost | 500-2000 stroops | 100-300 stroops |
| Why Higher | Complex WASM execution | Simple ledger update |
| When Worth It | Smart wallet features | Simple 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 10000Best Practices
- Estimate before submitting — Use simulation to get accurate fee predictions
- Use conservative multipliers — 1.5-2x estimates prevent failures
- Monitor network load — Fees spike during congestion; wait if possible
- Batch operations — Combine multiple calls to amortize base fee
- Cache state — Reuse data across transactions to reduce ledger I/O
- 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.