Skip to content

Latest commit

 

History

History
322 lines (238 loc) · 14.7 KB

File metadata and controls

322 lines (238 loc) · 14.7 KB

OphirPay — Gas Optimization & Benchmarking

Overview

OphirPay's Soroban contracts are optimized for ultra-low gas fees through:

  1. Individual counter keys instead of a monolithic ContractStats struct (40% storage write savings)
  2. Paginated version histories capped at 100 entries (prevents unbounded O(n) reads)
  3. Deduplicated owner checks via require_owner() helper (reduces Wasm code size)
  4. Zero-initialization design — counters default to 0 on first read, no deployment writes needed
  5. Zero-storage utility functionscalculate_fee() is pure computation, no storage access

Soroban Gas Model

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

Per-Operation Gas Benchmarks

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.

OphirPay Contract

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)

Emitter Contract

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

Cross-Contract Operations

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.

Optimization Design Rationale

1. Individual Counters vs ContractStats Monolith

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.

2. Paginated Version History

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.

3. Zero-Init Design

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.

4. Cross-Contract Pause Tradeoff

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.



🔬 Validated Against Testnet — Complete Audit (2026-08-07)

All measurements from Stellar Testnet, contract CAW7OOR..., fees via Horizon API fee_charged.

Re-benchmarked on the current deployed contracts (2026-08-14)

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).

Write Operations

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

Read-Only Operations

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

Estimated Operations (based on actual measurements + known byte counts)

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_charged

Locked-Funds Invariant (Gas Impact)

The 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.


Operational Notes

  • Contracterror variant count: OphirPay's PaymentError enum 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-dalek 3.0 / rand_chacha 0.3 trait incompatibility that broke cargo test on soroban-env-host 22.1.3 is resolved in soroban-sdk 27 (env-host pins ed25519-dalek = "2.0.0"). All 67 contract unit tests (60 ophirpay + 7 emitter) now run in CI via plain cargo 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.

Future Optimizations (Not Yet Implemented)

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

SDK Migration History

v22 migration (historical)

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.

v27 upgrade (current)

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.


Audit Summary — Gas Optimization Score

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 the rand_core/ed25519-dalek conflict from env-host 22.1.3). The WASM builds target wasm32v1-none (the protocol-25+ Soroban target).


Running Benchmarks

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 stroops

Note: 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.