OphirPay's Soroban contracts are optimized for ultra-low gas fees through:
- Individual counter keys instead of a monolithic
ContractStatsstruct (40% storage write savings) - Paginated version histories capped at 100 entries (prevents unbounded O(n) reads)
- Deduplicated owner checks via
require_owner()helper (reduces Wasm code size) - Zero-initialization design — counters default to 0 on first read, no deployment writes needed
- Zero-storage utility functions —
calculate_fee()is pure computation, no storage access
Soroban uses a resource-fee model with three cost tiers:
| Resource | Relative Cost | Example |
|---|---|---|
| Storage Write (persistent) | $$$ | Storing a payment record (~300 bytes) |
| Storage Write (instance) | $$ | Incrementing a counter (16 bytes) |
| Storage Read (persistent) | $$ | Fetching a payment by ID (~300 bytes) |
| Storage Read (instance) | $ | Reading a counter (16 bytes) |
| Event Emission | $$ | Publishing a payment event |
| Cross-contract Call | $$$$$ | Pausing the Emitter from OphirPay |
| CPU (Wasm) | $ | Arithmetic, comparisons, string ops |
Estimates based on the Soroban resource model (~100 gas/byte write, ~50 gas/byte read, ~30K gas/cross-contract call, ~2K gas/event). Benchmarked on soroban-sdk 27 (protocol 25+); stroop figures are SDK-version-agnostic at this granularity.
| Operation | Storage Writes | Storage Reads | Events | Est. Gas | Optimized vs Baseline |
|---|---|---|---|---|---|
init |
2 instance | 1 instance | 0 | ~3,000 | -60% (was 8 writes for counter pre-init) |
record_payment |
1 persistent + 2 instance | 1 instance | 1 | ~12,000 | -35% (was ContractStats monolith read/write) |
create_escrow |
1 persistent + 2 instance | 1 instance | 1 | ~14,000 | -35% |
create_stream |
1 persistent + 2 instance | 1 instance | 1 | ~14,000 | -35% |
create_batch (10 items) |
10 persistent + 3 instance | 1 instance | 1 | ~80,000 | -33% |
atomic_spend |
1 persistent + 2 instance | 2 persistent + 1 instance | 1 | ~35,000 | -35% |
approve_payment |
1 persistent | 2 persistent + 1 instance | 1 | ~22,000 | N/A |
vote_on_proposal |
1 persistent | 1 persistent | 1 | ~18,000 | N/A |
request_refund |
1 persistent + 1 instance | 1 instance | 1 | ~25,000 | -35% |
get_payment |
0 | 1 persistent | 0 | ~500 | N/A (read-only, already minimal) |
get_stats |
0 | 11 instance | 0 | ~1,500 | N/A (on-demand, reads individual counters) |
calculate_fee |
0 | 0 | 0 | ~100 | N/A (pure computation) |
| Operation | Storage Writes | Storage Reads | Events | Est. Gas |
|---|---|---|---|---|
init |
2 instance | 1 instance | 0 | ~3,000 |
emit_payment |
1 persistent + 1 instance | 1 instance | 1 | ~10,000 |
get_event |
0 | 1 persistent | 0 | ~500 |
pause / unpause |
1 instance | 1 instance | 0 | ~2,000 |
| Operation | Est. Gas (combined) | Note |
|---|---|---|
emergency_pause_all |
~50,000–80,000 | Includes cross-contract call (~30K). Higher cost justified by atomicity guarantee — both contracts pause in one transaction. No partial-pause attack possible. |
emergency_unpause_all |
~50,000–80,000 | Same atomicity guarantee. |
Before:
// Every counter increment: read 11-field struct (~200 bytes), modify 1 field, write back all 11
fn inc_stat_u64(env, get, set) {
let mut stats: ContractStats = env.storage().instance().get(&STATS).unwrap_or(...);
set(&mut stats, get(&stats).saturating_add(1));
env.storage().instance().set(&STATS, &stats); // ~200 bytes written
}After:
// Single-key read+write: 16 bytes each direction
fn inc_counter(env, key) {
let val: u64 = env.storage().instance().get(key).unwrap_or(0);
env.storage().instance().set(key, &val.saturating_add(1)); // 16 bytes written
}Savings: 200 bytes → 16 bytes per write = ~92% reduction in storage write bytes.
Both get_multisig_config_history() and get_fee_config_history() are capped at 100 entries (most recent first). Without this cap, a contract with 10,000 config changes would require 10,000 persistent storage reads (~500,000 gas) — effectively a DoS vector.
Single-version lookup (get_fee_config_at_version(version)) is still available for arbitrary version access.
Soroban instance storage returns None for unset keys. Our counters default to 0 on first .unwrap_or(0), so init() doesn't waste gas pre-writing zero values. This saves 4+ instance writes (~2,000 gas) on contract deployment.
emergency_pause_all() makes a cross-contract call to the Emitter (~30K gas). This is the most expensive single operation but is intentional:
- Security: Atomicity prevents an attacker from pausing only one contract
- Frequency: Emergency pause is a rare admin action, not a hot-path operation
- Alternative: Without cross-contract pause, an attacker could pause OphirPay but leave the Emitter running, causing event loss
Tradeoff accepted: higher one-time cost for critical security guarantee.
All measurements from Stellar Testnet, contract CAW7OOR..., fees via Horizon API fee_charged.
After the fresh deployment of the 300-variant WASM (CCQGGUJRR..., version 2), write operations
were re-measured live on testnet via Horizon fee_charged:
| Operation | Stroops | XLM | Successful |
|---|---|---|---|
record_payment (payer, payee, amount, asset, tx_hash, metadata) |
103,967 | 0.104 | ✅ |
grant_role (Admin) |
70,100 | 0.070 | ✅ |
set_fee_config (6-field struct + version archive) |
127,115 | 0.127 | ✅ |
propose_timelocked_action |
96,018 | 0.096 | ✅ |
All read-only operations (get_stats, get_fee_config, get_payment_count) cost 0 stroops —
simulated, no transaction required. The 2026-08-07 figures below remain valid as reference
measurements from the earlier (199-variant) deployment; the current build is within the same
cost band (avg ~90–130K stroops per write).
| Operation | Stroops | XLM | TX Hash | Notes |
|---|---|---|---|---|
init |
17,237 | 0.017 | f71a20cd... |
One-time deployment init |
record_payment |
69,715 | 0.070 | 7043a2d4... |
Record-keeping only, no token transfer |
create_escrow |
168,644 | 0.169 | 31d389b8... |
Includes SAC token transfer (~99K extra) |
set_fee_config |
123,778 | 0.124 | 71eac64f... |
Writes 6-field struct + archives version |
propose_timelocked_action |
54,360 | 0.054 | af9d06fe... |
1 persistent write + 1 instance + 2 events |
revoke_role |
48,231 | 0.048 | 658c736b... |
Removes persistent entry + audit event |
| Operation | Cost | Notes |
|---|---|---|
get_stats |
0 stroops | Pure simulation, no TX needed |
get_fee_config |
0 stroops | Instance storage read |
get_payment |
0 stroops | Persistent storage read via simulation |
get_multisig_config_history |
0 stroops | Read-only paginated query |
| Operation | Est. Stroops | Est. XLM | Basis |
|---|---|---|---|
create_stream |
~168,000 | ~0.168 | Same as create_escrow (SAC transfer + storage) |
create_batch (10 items) |
~450,000 | ~0.450 | 10 persistence writes + SAC transfers |
approve_payment (multisig) |
~70,000 | ~0.070 | Similar to record_payment |
request_refund |
~70,000 | ~0.070 | Persistent write + instance counter |
approve_refund |
~50,000 | ~0.050 | Single persistent write |
grant_role |
~50,000 | ~0.050 | Similar to revoke_role |
vote_on_proposal |
~55,000 | ~0.055 | Persistent write + event |
execute_approved_payment |
~80,000 | ~0.080 | Includes payment execution |
Note: Total fees = classic base fee (~100 stroops) + Soroban resource fee. Operations marked with "SAC" interact with the Stellar Asset Contract for token transfers, adding ~99K stroops overhead. All read-only operations cost 0 stroops — they use Soroban simulation which is free and doesn't require a transaction.
To reproduce:
stellar keys generate --fund --network testnet --rpc-url https://soroban-testnet.stellar.org test-key
stellar contract invoke --id <CONTRACT_ID> --source-account test-key \
--network testnet --rpc-url https://soroban-testnet.stellar.org \
-- record_payment --payer <ADDR> --payee <ADDR> --amount 1000 \
--asset native --tx-hash test --metadata "benchmark"
# Then check fee on Horizon:
curl -s "https://horizon-testnet.stellar.org/transactions/<TX_HASH>" | jq .fee_chargedThe LOCKED_BALANCE tracking system (see docs/SPEC.md INV-3) adds one
extra instance storage read + write per fund-movement operation:
| Operation | Extra gas (locked tracking) | Percentage overhead |
|---|---|---|
create_escrow / create_stream |
~300 gas (1 read + 1 write, 16 bytes each) | ~2% |
release_escrow / claim_escrow |
~300 gas | ~2% |
claim_stream |
~300 gas | ~2% |
cancel_stream |
~300 gas | ~2% |
emergency_withdraw |
~450 gas (1 read + 1 balance check + 1 transfer) | N/A (admin-only) |
Tradeoff accepted: ~2% gas overhead per fund-movement operation in exchange for preventing the owner from draining user-deposited funds. This is the correct security/gas tradeoff per the Stellar Drips Wave Bot review.
-
Contracterror variant count: OphirPay's
PaymentErrorenum defines 300#[contracterror]variants and compiles cleanly on soroban-sdk 27. If the catalog grows further, consider hierarchical error codes or splitting into multiple error enums. -
Host test compilation: Fixed. The
ed25519-dalek3.0 /rand_chacha0.3 trait incompatibility that brokecargo testonsoroban-env-host22.1.3 is resolved in soroban-sdk 27 (env-host pinsed25519-dalek = "2.0.0"). All 67 contract unit tests (60 ophirpay + 7 emitter) now run in CI via plaincargo test. -
WASM size: OphirPay contract is 92 KB (94,096 bytes) with
opt-level="z". The Soroban mainnet upload limit is ~128 KB (131,072 bytes). Headroom: ~36 KB for future features.
| Optimization | Est. Savings | Priority | Effort |
|---|---|---|---|
| TTL batching per function | 15–20% per op | Medium | Low |
Audit action names as Symbol instead of String |
~2K CPU/audit | Low | Medium |
| Reduce event payload sizes | ~1K/event | Low | Low |
| Lazy audit (event-only, skip persistent storage) | ~5K/audit (optional) | Low | High |
The OphirPay contract was ported from soroban-sdk pre-v22 to v22.0.11 (Rust 1.88.0). Key API changes addressed:
| Change | Impact | Lines |
|---|---|---|
get::<V, _>(&key) to get::<_, V>(&key) |
K/V generic order reversed (K=key first in v22) | 8 sites |
Address no longer Deref |
*s == signer to s == signer |
4 sites |
Vec::get_mut() removed |
Replaced with get() + set() pattern |
1 site |
| Clone-before-move required | Added clone() before struct initialization |
4 sites |
Option<Address> unwrap in batch |
Explicit .ok_or() added |
2 sites |
Migration result: 30 compile errors to 0 errors.
Upgraded both contracts from v22 to soroban-sdk 27.0.5 (Rust 1.91.0,
wasm32v1-none target). This fixed the env-host dependency conflict that
blocked cargo test — all 67 contract unit tests (60 ophirpay + 7 emitter)
now run in CI. It also surfaced and fixed a critical storage bug: all record
types previously shared the same plain u64 persistent key space, so e.g. the
first audit entry silently overwrote the first payment. Records are now
namespaced under (PREFIX, id) tuple keys.
Known follow-up: env.events().publish is deprecated in v27 in favor of the
#[contractevent] macro (19 warnings). Migration deferred — it changes the
on-chain event topic format that the app's event parser consumes; tracked as a
separate change with frontend compatibility tests.
---## WASM Binary Sizes
Compiled with Rust 1.91.0, soroban-sdk 27.0.5, --release, wasm32v1-none, opt-level = "z":
| Contract | Size | Headroom |
|---|---|---|
ophirpay_contract.wasm |
94,096 bytes (91.9 KB) | 36.1 KB (mainnet limit: 128 KB) |
ophirpay_emitter.wasm |
7,338 bytes (7.2 KB) | 121 KB (mainnet limit: 128 KB) |
The OphirPay contract at 92 KB is reasonable for its scope (~5,600 lines, 90+ functions, 21 struct types, 300 error variants). The Emitter at 7 KB shows what a minimal Soroban contract looks like.
| Metric | Value | Rating |
|---|---|---|
| Average write TX cost | ~90K stroops | 🟢 Excellent (sub-0.1 XLM) |
| Cheapest write TX | 17K stroops (init) | 🟢 Minimal |
| Most expensive TX | 169K stroops (create_escrow) | 🟡 Expected (SAC transfer) |
| Read operations | 0 stroops (all) | 🟢 Free |
| WASM size | 92 KB / 128 KB limit | 🟢 72% utilized |
| Storage efficiency | 16 bytes/counter | 🟢 92% vs monolith |
| Locked-funds overhead | ~2% per op | 🟢 Acceptable |
Verdict: The OphirPay contract is production-ready for gas efficiency. All write operations cost under 0.17 XLM, most under 0.10 XLM. The 92 KB WASM leaves ample headroom for future features. The cross-contract emergency pause (~80K gas estimate) is the only operation above 0.20 XLM and is justified by its atomicity guarantee.
Note: Contract unit tests run natively with
cargo test(soroban-sdk 27 fixed therand_core/ed25519-dalekconflict from env-host 22.1.3). The WASM builds targetwasm32v1-none(the protocol-25+ Soroban target).
To benchmark actual gas usage on Stellar Testnet:
# Deploy contracts
./scripts/deploy-workflow.sh <SECRET_KEY> <OWNER_KEY> <EMITTER_ID>
# Call record_payment and check the TX fee
stellar contract invoke \
--id <CONTRACT_ID> \
--source <SECRET_KEY> \
--network testnet \
-- record_payment \
--payer <ADDR> --payee <ADDR> --amount 1000 \
--asset native --tx-hash test
# The transaction receipt will show the actual resource fee in stroopsNote: Actual mainnet gas costs depend on network congestion and Soroban's dynamic fee model. These estimates are based on the static resource pricing model and should be validated against real testnet/mainnet transactions.