Skip to content

Latest commit

 

History

History
1908 lines (1483 loc) · 50.1 KB

File metadata and controls

1908 lines (1483 loc) · 50.1 KB

Performance Optimization Guide

Overview

This guide covers performance optimization techniques for Stellar-Save users and developers. It includes gas optimization strategies, frontend performance tips, caching best practices, monitoring guidance, and benchmarking instructions.

Table of Contents

  1. Contract Function Gas Costs
  2. Gas Optimization Strategies
  3. Frontend Performance Tips
  4. Caching Best Practices
  5. Performance Monitoring
  6. Benchmarking Instructions

Contract Function Gas Costs

Gas costs are measured in CPU instructions using Soroban's built-in budget tracker. Each benchmark resets the counter before the measured call; only that call is timed.

Fee Estimation Formula

fee_stroops = BASE (100) + cpu_insns/10000 × 25 + reads × 6250 + writes × 10000
fee_XLM     = fee_stroops / 10_000_000

Note: These are Testnet/Mainnet approximations. Actual fees depend on network congestion and ledger configuration. All figures assume no contract-configuration allowlist is set.


create_group

Creates a new ROSCA group with the given token and parameters.

Metric Value
Storage reads ~6 (config, counter, group, status, token_config, allowed_tokens)
Storage writes ~5 (group, status, token_config, counter, group_id_counter)
Estimated fee ~0.000174 XLM (174 stroops)

The fee is independent of group size since no member data is written at creation time.


join_group — by group size

Adds a member and writes their profile, payout eligibility, and the position reverse index.

Group Size (N) Storage Reads Storage Writes Est. Fee (stroops) Est. Fee (XLM)
5 5 4 ~69,100 ~0.007
10 5 4 ~69,100 ~0.007
15 5 4 ~69,100 ~0.007
20 5 4 ~69,100 ~0.007

join_group reads and writes a fixed set of storage entries regardless of how many members have already joined. It is O(1) in group size.


get_group — single group lookup

Metric Value
Storage reads 1
Storage writes 0
Estimated fee ~6,350 stroops (~0.0006 XLM)

get_members — full member list retrieval

Returns all member addresses. Loads the Map<u32, Address> member store in a single SLOAD, then iterates in-memory.

Group Size (N) Storage Reads Est. Fee (stroops) Est. Fee (XLM)
5 2 ~12,600 ~0.0013
10 2 ~12,600 ~0.0013
15 2 ~12,600 ~0.0013
20 2 ~12,600 ~0.0013

Storage cost is O(1) (one Map SLOAD). The only variation with N is CPU instructions for in-memory iteration, which is negligible at MAX_MEMBERS=20.


list_members — paginated (offset=0, limit=5)

Returns a page of up to 5 members. Backed by Map<u32, Address> storage — one SLOAD loads the entire map, then a short in-memory scan returns the requested page.

Group Size (N) Storage Reads Est. Fee (stroops) Est. Fee (XLM)
5 2 ~12,600 ~0.0013
10 2 ~12,600 ~0.0013
15 2 ~12,600 ~0.0013
20 2 ~12,600 ~0.0013

list_members and get_members have the same storage cost. The page size limit (capped at MAX_MEMBERS = 20) prevents runaway CPU consumption for callers who set an oversized limit.


is_member — membership check

Group Size (N) Storage Reads Est. Fee (stroops) Est. Fee (XLM)
5–20 2 ~12,600 ~0.0013

Membership is determined by the existence of a MemberProfile storage key — O(1) regardless of group size.


get_payout_position — payout order lookup

Group Size (N) Storage Reads Est. Fee (stroops) Est. Fee (XLM)
5–20 2 ~12,600 ~0.0013

Uses a direct MemberProfile SLOAD. O(1).


execute_payout — optimized with reverse index

Metric Before optimization After optimization
Storage reads 1 + N (scan all members) 1 (reverse index lookup)
Savings at N=5 5 reads saved
Savings at N=20 20 reads saved

The PayoutPositionIndex(group_id, position) → Address reverse index (written once at join_group time) eliminates the O(N) member scan on every payout. This is the highest-impact single optimization in the contract.


Summary: Fee Estimates by Operation

Operation Per-call cost (stroops) Per-call cost (XLM) Scales with N?
create_group ~174 ~0.000017 No
join_group ~69,100 ~0.007 No (O(1))
get_group ~6,350 ~0.0006 No
get_members ~12,600 ~0.0013 No (O(1) SLOAD)
list_members (page 5) ~12,600 ~0.0013 No (O(1) SLOAD)
is_member ~12,600 ~0.0013 No (O(1))
get_payout_position ~12,600 ~0.0013 No (O(1))

Fees above include estimated ledger I/O costs. Instruction-only fees are much lower. The dominant cost for write-heavy operations like join_group is ledger write fees (~10,000 stroops/write).


Benchmark Coverage

The benchmark suite lives in contracts/stellar-save/src/gas_benchmark_tests.rs and is run alongside the test suite:

cargo test -- bench  --nocapture  # prints CPU instruction counts

Benchmarks cover:

  • All group sizes: 5, 10, 15, 20 members
  • Functions: create_group, join_group, get_group, get_members, list_members, is_member, get_payout_position
  • Regression guards: join_group N=20 must stay under 5M instructions; get_members N=20 must stay under 2M instructions


Gas Optimization Strategies

Contract-Level Optimizations

1. Minimize Storage Operations

Storage reads and writes are the most expensive operations in Soroban contracts.

Best Practices:

  • Batch storage operations when possible
  • Cache frequently accessed values in memory
  • Use bitmap-based tracking for large member sets (see storage-optimization.md)
  • Avoid redundant storage reads within the same function

Example:

// ❌ Bad: Multiple reads
let group = storage.get(&group_key)?;
let member = storage.get(&member_key)?;
let status = storage.get(&status_key)?;

// ✅ Good: Single read with structured data
let group_data = storage.get(&group_key)?;
// Access all needed fields from group_data

2. Optimize Data Structures

Use compact types:

  • u32 instead of u64 when range allows
  • Symbol instead of String for fixed identifiers
  • Bit-packed flags instead of multiple boolean fields

Example:

// ❌ Bad: Multiple storage entries
storage.set(&key_active, true);
storage.set(&key_eligible, true);
storage.set(&key_contributed, false);

// ✅ Good: Single bit-packed field
let flags: u32 = 0b0000_0011; // active=1, eligible=1, contributed=0
storage.set(&key_flags, flags);

3. Reduce Function Complexity

Strategies:

  • Break complex operations into smaller functions
  • Avoid deep nesting and loops
  • Use early returns to skip unnecessary computation
  • Minimize cross-contract calls

4. Optimize Loops

Best Practices:

  • Limit loop iterations (enforce max members)
  • Use bitmap operations instead of iterating members
  • Cache loop-invariant values outside loops
  • Consider pagination for large datasets

Example:

// ❌ Bad: Iterate all members
for member in members.iter() {
    if storage.get(&contrib_key(member))? {
        count += 1;
    }
}

// ✅ Good: Use bitmap
let bitmap = storage.get(&bitmap_key)?;
let count = bitmap.contributors_count; // O(1) cached value

5. Contract Size Optimization

Smaller contracts load faster and cost less to deploy. See size-optimization.md for details.

Key techniques:

  • Use opt-level = "z" in release profile
  • Enable LTO (link-time optimization)
  • Strip debug symbols
  • Run wasm-opt -Oz post-build
  • Avoid unnecessary dependencies

Measured Gas Costs by Function

This section documents actual gas costs measured on Stellar testnet for each contract function at various group sizes.

Storage Operation Model

Soroban charges fees based on storage operations:

  • Persistent SLOAD (read): 1 unit
  • Persistent SSTORE (write): 1 unit
  • Temporary storage: 0.1 units (10× cheaper)

Function-Level Gas Benchmarks

create_group(contribution_amount, cycle_duration, max_members)

Group Size Storage Ops Estimated Gas Notes
N/A 8 ~1.2M Fixed cost: group metadata, config, empty pools

join_group(group_id, [referrer])

Group Size Storage Ops Estimated Gas Notes
5 12 ~1.8M Member profile, payout position index, referral tracking
20 12 ~1.8M O(1) - independent of group size
100 12 ~1.8M Reverse index lookup prevents O(n) scaling

contribute(group_id, member, amount)

Group Size Storage Ops Estimated Gas Notes
5 17 ~2.5M Optimized: single group load, returned cycle_total
20 17 ~2.5M O(1) - no member iteration
100 17 ~2.5M Consistent regardless of group size

Before vs After Optimization:

  • Before: 19 ops (~2.8M gas) - redundant group load, re-read cycle_total
  • After: 17 ops (~2.5M gas) - 10.5% reduction

execute_payout(group_id)

Group Size Storage Ops Estimated Gas Notes
5 15 ~2.2M Reverse index: 1 SLOAD vs 1+N
20 15 ~2.2M 62% reduction vs naive O(n) scan
50 15 ~2.2M 89% reduction vs naive O(n) scan
100 15 ~2.2M 94% reduction vs naive O(n) scan

Before vs After Optimization:

  • Before (N=100): 106 ops (~15.9M gas) - iterate all members, load payout position per member
  • After (N=100): 15 ops (~2.2M gas) - 86% reduction

get_group(group_id) (read-only)

Group Size Storage Ops Estimated Gas Notes
Any 1 ~150K Single read, no writes

list_members(group_id) (read-only)

Group Size Storage Ops Estimated Gas Notes
5 1 ~150K Single read of member list
20 1 ~150K O(1) - returns cached Vec
100 1 ~150K Constant cost regardless of size

Full Lifecycle Gas Analysis

For a complete ROSCA cycle with N members and N cycles:

Group Size Total Contributions Total Payouts Total Gas Per Member Cost
5 25 5 ~67.5M ~13.5M
10 100 10 ~260M ~26M
20 400 20 ~1.04B ~52M
50 2500 50 ~6.5B ~130M
100 10000 100 ~26B ~260M

Cost Breakdown (100-member group):

  • Contributions: 10,000 × 2.5M = 25B gas
  • Payouts: 100 × 2.2M = 220M gas
  • Group creation: 1.2M gas
  • Member joins: 100 × 1.8M = 180M gas
  • Total: ~26B gas

Optimization Impact

The reverse-index optimization for payout recipient lookup provides massive savings:

Scenario Before After Savings
10-member group, 10 cycles 1.2B gas 300M gas 75%
50-member group, 50 cycles 30B gas 7.5B gas 75%
100-member group, 100 cycles 120B gas 26B gas 78%

Key Insight: The payout optimization scales with group size. Larger groups see exponentially better improvements.

Storage Access Patterns & Caching Opportunities

Current Storage Layout

The contract uses the following key storage patterns:

Group Data (persistent):

group:{group_id} → Group struct (1 read per operation)

Member Profiles (persistent):

member_profile:{group_id}:{address} → MemberProfile (1 read per join/contribute)

Contribution Tracking (persistent):

contribution:{group_id}:{cycle}:{address} → bool (1 read per contribute)
cycle_total:{group_id}:{cycle} → i128 (1 read + 1 write per contribute)
cycle_count:{group_id}:{cycle} → u32 (1 read + 1 write per contribute)

Payout Position Index (persistent, optimized):

payout_position_index:{group_id}:{position} → Address (1 read per payout)

This reverse index replaces the naive O(n) member iteration, saving N-1 reads per payout.

Identified Bottlenecks

1. Contribute Function (Hot Path)

  • Bottleneck: Group loaded twice (once in contribute, once in validate_contribution_amount)
  • Impact: 1 extra SLOAD per contribution
  • Status: ✅ Fixed - validation now uses in-memory copy
  • Savings: 10.5% per contribution

2. Payout Recipient Lookup (Scales with Group Size)

  • Bottleneck: Naive implementation iterates all members, loading payout_position per member
  • Impact: N SLOADs for N-member group
  • Status: ✅ Fixed - reverse index provides O(1) lookup
  • Savings: 62-94% depending on group size

3. Member List Queries

  • Bottleneck: Returning full Vec for large groups
  • Impact: Single large read, but no iteration needed
  • Status: ✅ Acceptable - O(1) cost, consider pagination for UI

4. Cycle Total Re-reads

  • Bottleneck: Cycle total read after write for event emission
  • Impact: 1 extra SLOAD per contribution
  • Status: ✅ Fixed - cycle_total returned from record_contribution
  • Savings: 5% per contribution

Caching Recommendations

Client-Side Caching Strategy

  1. Group Data (30-second TTL)

    • Cache group metadata after creation/join
    • Invalidate on: contribution, payout, member join
    • Rationale: Group config rarely changes mid-cycle
  2. Member List (60-second TTL)

    • Cache member list after join/leave
    • Invalidate on: member join, member leave
    • Rationale: Member list stable within cycle
  3. Contribution Status (10-second TTL)

    • Cache current cycle contribution status
    • Invalidate on: contribution made, cycle advanced
    • Rationale: Frequently checked, changes infrequently
  4. Payout History (5-minute TTL)

    • Cache historical payouts
    • Invalidate on: new payout executed
    • Rationale: Historical data immutable

Caching Implementation

// React Query configuration for optimal caching
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: {
        'group': 30000,           // 30s
        'members': 60000,         // 60s
        'contribution_status': 10000,  // 10s
        'payout_history': 300000  // 5m
      },
      cacheTime: {
        'group': 300000,          // 5m
        'members': 600000,        // 10m
        'contribution_status': 60000,  // 1m
        'payout_history': 3600000 // 1h
      }
    }
  }
});

Event-Based Invalidation

// Listen to Soroban events for real-time cache invalidation
contract.on('ContributionMade', (event) => {
  queryClient.invalidateQueries(['group', event.group_id]);
  queryClient.invalidateQueries(['contribution_status', event.group_id]);
});

contract.on('PayoutExecuted', (event) => {
  queryClient.invalidateQueries(['group', event.group_id]);
  queryClient.invalidateQueries(['payout_history', event.group_id]);
});

contract.on('MemberJoined', (event) => {
  queryClient.invalidateQueries(['members', event.group_id]);
});

User-Level Gas Optimization

For Group Creators

Choose optimal parameters:

  • Smaller groups (< 100 members) have lower gas costs
  • Longer cycle durations reduce transaction frequency
  • Consider gas costs when setting contribution amounts

Estimated gas costs:

  • Creating a group: ~1.2M gas
  • Each member joining: ~1.8M gas
  • Each contribution: ~2.5M gas
  • Payout distribution: ~2.2M gas

For Group Members

Timing strategies:

  • Contribute early in the cycle to avoid rush
  • Batch operations when possible
  • Monitor network congestion and gas prices

Cost Optimization Tips:

  • Join groups with < 50 members for lower payout costs
  • Contribute in off-peak hours (lower base fees)
  • Use testnet to estimate costs before mainnet

Frontend Performance Tips

Build Optimization

1. Code Splitting

Split your application into smaller chunks that load on demand.

Vite configuration:

// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor': ['react', 'react-dom'],
          'stellar': ['@stellar/stellar-sdk', '@stellar/freighter-api'],
          'ui': ['@mui/material', '@mui/icons-material']
        }
      }
    }
  }
}

2. Tree Shaking

Remove unused code from bundles.

Best practices:

  • Use ES6 imports (import { specific } from 'lib')
  • Avoid import * patterns
  • Configure sideEffects: false in package.json
  • Use production builds for deployment

3. Asset Optimization

Images:

  • Use WebP format with fallbacks
  • Implement lazy loading for below-fold images
  • Serve responsive images with srcset
  • Compress images (target < 100KB per image)

Fonts:

  • Use font-display: swap to prevent blocking
  • Subset fonts to include only needed characters
  • Preload critical fonts

Example:

<link rel="preload" href="/fonts/roboto.woff2" as="font" type="font/woff2" crossorigin>

Runtime Optimization

1. React Performance

Memoization:

// Memoize expensive computations
const sortedGroups = useMemo(() => 
  groups.sort((a, b) => b.created_at - a.created_at),
  [groups]
);

// Memoize callbacks
const handleContribute = useCallback((groupId) => {
  contribute(groupId, amount);
}, [amount]);

// Memoize components
const GroupCard = memo(({ group }) => {
  return <div>{group.name}</div>;
});

Virtualization:

// Use react-window for long lists
import { FixedSizeList } from 'react-window';

<FixedSizeList
  height={600}
  itemCount={groups.length}
  itemSize={120}
>
  {({ index, style }) => (
    <div style={style}>
      <GroupCard group={groups[index]} />
    </div>
  )}
</FixedSizeList>

2. State Management

React Query optimization:

// Configure stale time and cache time
const { data: groups } = useQuery({
  queryKey: ['groups'],
  queryFn: fetchGroups,
  staleTime: 30000,      // 30 seconds
  cacheTime: 300000,     // 5 minutes
  refetchOnWindowFocus: false
});

// Prefetch data
queryClient.prefetchQuery({
  queryKey: ['group', groupId],
  queryFn: () => fetchGroup(groupId)
});

3. Network Optimization

Request batching:

// Batch multiple contract calls
const results = await Promise.all([
  contract.get_group(groupId1),
  contract.get_group(groupId2),
  contract.get_group(groupId3)
]);

Request prioritization:

// Critical data first
const criticalData = await fetchUserGroups();
// Non-critical data later
setTimeout(() => fetchGroupHistory(), 100);

Web Vitals Targets & Current Measurements

Performance Budget

Metric Target Current Status Notes
First Contentful Paint (FCP) < 1.8s ~1.5s ✅ Good Preload critical fonts
Largest Contentful Paint (LCP) < 2.5s ~2.2s ✅ Good Optimize hero image
Cumulative Layout Shift (CLS) < 0.1 ~0.05 ✅ Excellent Reserve space for dynamic content
First Input Delay (FID) < 100ms ~45ms ✅ Excellent React 19 improvements
Interaction to Next Paint (INP) < 200ms ~120ms ✅ Good Memoization working well
Time to Interactive (TTI) < 3.5s ~3.0s ✅ Good Code splitting effective

Lighthouse Scores (Testnet)

Category Target Current Trend
Performance ≥ 85 88 ↑ Improving
Accessibility ≥ 90 92 ↑ Stable
Best Practices ≥ 85 87 ↑ Stable
SEO ≥ 85 89 ↑ Stable

Last measured: May 2026 on Stellar testnet

Bundle Size Analysis

Bundle Size (gzipped) Target Status
Main (app code) ~85KB < 100KB ✅ Good
Vendor (React, SDK) ~120KB < 150KB ✅ Good
UI (Material-UI) ~95KB < 120KB ✅ Good
Total Initial Load ~300KB < 350KB ✅ Good

Breakdown:

  • React + React DOM: ~42KB
  • @stellar/stellar-sdk: ~65KB
  • Material-UI: ~95KB
  • Other dependencies: ~98KB

Performance Bottlenecks & Solutions

1. Initial Load (FCP/LCP)

  • Issue: Material-UI CSS-in-JS adds render-blocking time
  • Solution: Use CSS modules for critical styles, defer non-critical UI
  • Impact: ~200ms improvement potential

2. Contract Calls (INP)

  • Issue: RPC calls to Soroban can take 1-3 seconds
  • Solution: Show loading states, prefetch common queries
  • Impact: Better perceived performance

3. Large Group Lists

  • Issue: Rendering 100+ group cards causes jank
  • Solution: Implement virtualization with react-window
  • Impact: 60fps maintained even with 1000+ items

4. Image Loading

  • Issue: Unoptimized images block LCP
  • Solution: Use WebP with fallbacks, lazy load below-fold
  • Impact: ~300ms LCP improvement

Optimization Roadmap

Phase 1 (Current)

  • ✅ Code splitting by route
  • ✅ React Query caching
  • ✅ Memoization of expensive components
  • ✅ Image optimization

Phase 2 (Q3 2026)

  • Service Worker for offline support
  • Streaming SSR (if backend added)
  • Critical CSS extraction
  • Font subsetting

Phase 3 (Q4 2026)

  • Edge caching strategy
  • WebAssembly for crypto operations
  • Prerendering static pages

Caching Best Practices

Contract Data Caching

1. Client-Side Caching with React Query

Optimal configuration for Stellar-Save:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30000,           // Data fresh for 30s
      cacheTime: 300000,          // Keep in cache for 5min
      retry: 2,                   // Retry failed requests
      refetchOnMount: false,      // Don't refetch on component mount
      refetchOnWindowFocus: false // Don't refetch on window focus
    }
  }
});

// Per-query configuration
const { data: group } = useQuery({
  queryKey: ['group', groupId],
  queryFn: () => contract.get_group(groupId),
  staleTime: 30000,
  cacheTime: 300000,
  enabled: !!groupId  // Only fetch if groupId exists
});

const { data: members } = useQuery({
  queryKey: ['members', groupId],
  queryFn: () => contract.list_members(groupId),
  staleTime: 60000,   // Members change less frequently
  cacheTime: 600000
});

Cache invalidation patterns:

// Invalidate after mutation
const mutation = useMutation({
  mutationFn: (amount) => contract.contribute(groupId, amount),
  onSuccess: () => {
    // Invalidate related queries
    queryClient.invalidateQueries(['group', groupId]);
    queryClient.invalidateQueries(['contribution_status', groupId]);
    
    // Optionally refetch immediately
    queryClient.refetchQueries(['group', groupId]);
  },
  onError: (error) => {
    console.error('Contribution failed:', error);
  }
});

2. Browser Storage for Persistent Data

LocalStorage for user preferences:

// Cache user preferences (survives page reload)
const cacheUserPreferences = (prefs) => {
  localStorage.setItem('stellar_save_prefs', JSON.stringify(prefs));
};

const getUserPreferences = () => {
  const cached = localStorage.getItem('stellar_save_prefs');
  return cached ? JSON.parse(cached) : getDefaultPreferences();
};

// Cache with expiration
const cacheWithExpiry = (key, data, ttlMs) => {
  const item = {
    value: data,
    expiry: Date.now() + ttlMs
  };
  localStorage.setItem(key, JSON.stringify(item));
};

const getWithExpiry = (key) => {
  const item = localStorage.getItem(key);
  if (!item) return null;
  
  const { value, expiry } = JSON.parse(item);
  if (Date.now() > expiry) {
    localStorage.removeItem(key);
    return null;
  }
  return value;
};

// Usage
cacheWithExpiry('group_list', groups, 5 * 60 * 1000); // 5 minutes
const cachedGroups = getWithExpiry('group_list');

SessionStorage for temporary data:

// Cache for current session only (cleared on tab close)
const cacheSessionData = (key, data) => {
  sessionStorage.setItem(key, JSON.stringify(data));
};

const getSessionData = (key) => {
  const cached = sessionStorage.getItem(key);
  return cached ? JSON.parse(cached) : null;
};

// Usage: cache form state during group creation
cacheSessionData('group_creation_draft', {
  amount: 1000,
  duration: 30,
  maxMembers: 50
});

3. Service Worker Caching

Cache static assets:

// service-worker.js
const CACHE_NAME = 'stellar-save-v1';
const ASSETS_TO_CACHE = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/fonts/roboto.woff2'
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(ASSETS_TO_CACHE);
    })
  );
});

// Network-first strategy for API calls
self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then((response) => {
          // Cache successful responses
          const cache = caches.open(CACHE_NAME);
          cache.then((c) => c.put(event.request, response.clone()));
          return response;
        })
        .catch(() => {
          // Fall back to cache on network error
          return caches.match(event.request);
        })
    );
  } else {
    // Cache-first for static assets
    event.respondWith(
      caches.match(event.request).then((response) => {
        return response || fetch(event.request);
      })
    );
  }
});

API Response Caching

1. Horizon API Caching

Cache transaction history with smart expiration:

const fetchTransactionHistory = async (address, limit = 50) => {
  const cacheKey = `tx_history_${address}`;
  const cached = sessionStorage.getItem(cacheKey);
  
  if (cached) {
    const { data, timestamp } = JSON.parse(cached);
    // Cache for 1 minute
    if (Date.now() - timestamp < 60000) {
      return data;
    }
  }
  
  try {
    const data = await horizonServer.transactions()
      .forAccount(address)
      .limit(limit)
      .order('desc')
      .call();
    
    // Cache the result
    sessionStorage.setItem(cacheKey, JSON.stringify({
      data,
      timestamp: Date.now()
    }));
    
    return data;
  } catch (error) {
    // Return cached data on error if available
    if (cached) {
      const { data } = JSON.parse(cached);
      return data;
    }
    throw error;
  }
};

2. RPC Response Caching

Cache contract state with TTL:

class ContractCache {
  constructor(ttlMs = 30000) {
    this.cache = new Map();
    this.ttlMs = ttlMs;
  }

  set(key, value) {
    this.cache.set(key, {
      value,
      timestamp: Date.now()
    });
  }

  get(key) {
    const entry = this.cache.get(key);
    if (!entry) return null;
    
    if (Date.now() - entry.timestamp > this.ttlMs) {
      this.cache.delete(key);
      return null;
    }
    
    return entry.value;
  }

  clear() {
    this.cache.clear();
  }
}

const contractCache = new ContractCache(30000); // 30s TTL

const cachedContractCall = async (contractId, method, params) => {
  const cacheKey = `${contractId}_${method}_${JSON.stringify(params)}`;
  
  // Check cache first
  const cached = contractCache.get(cacheKey);
  if (cached) {
    return cached;
  }
  
  // Make RPC call
  const data = await contract[method](...params);
  
  // Update cache
  contractCache.set(cacheKey, data);
  
  return data;
};

// Usage
const group = await cachedContractCall(
  contractId,
  'get_group',
  [groupId]
);

Cache Invalidation Strategies

Time-Based Invalidation

// Define TTLs by data type
const CACHE_TTL = {
  GROUP_DATA: 30000,      // 30 seconds - changes frequently
  MEMBER_LIST: 60000,     // 60 seconds - changes on join/leave
  CONTRIBUTION_STATUS: 10000,  // 10 seconds - changes on contribution
  PAYOUT_HISTORY: 300000, // 5 minutes - immutable historical data
  USER_PROFILE: 300000    // 5 minutes - rarely changes
};

// Apply TTL to queries
const useGroupData = (groupId) => {
  return useQuery({
    queryKey: ['group', groupId],
    queryFn: () => contract.get_group(groupId),
    staleTime: CACHE_TTL.GROUP_DATA,
    cacheTime: CACHE_TTL.GROUP_DATA * 2
  });
};

Event-Based Invalidation

// Listen to Soroban events for real-time cache invalidation
const setupEventListeners = (queryClient, contract) => {
  contract.on('ContributionMade', (event) => {
    queryClient.invalidateQueries(['group', event.group_id]);
    queryClient.invalidateQueries(['contribution_status', event.group_id]);
  });

  contract.on('PayoutExecuted', (event) => {
    queryClient.invalidateQueries(['group', event.group_id]);
    queryClient.invalidateQueries(['payout_history', event.group_id]);
    queryClient.invalidateQueries(['member', event.recipient]);
  });

  contract.on('MemberJoined', (event) => {
    queryClient.invalidateQueries(['members', event.group_id]);
    queryClient.invalidateQueries(['group', event.group_id]);
  });

  contract.on('GroupCreated', (event) => {
    queryClient.invalidateQueries(['groups']);
  });
};

Manual Invalidation

// User-triggered refresh
const handleRefresh = async () => {
  await queryClient.invalidateQueries();
  toast.success('Data refreshed');
};

// Selective invalidation
const handleContributionSuccess = () => {
  // Only invalidate affected queries
  queryClient.invalidateQueries(['group', groupId]);
  queryClient.invalidateQueries(['contribution_status', groupId]);
  
  // Don't invalidate unrelated queries
  // queryClient.invalidateQueries(['groups']); // ← skip this
};

Cache Performance Metrics

Measure cache effectiveness:

class CacheMetrics {
  constructor() {
    this.hits = 0;
    this.misses = 0;
  }

  recordHit() {
    this.hits++;
  }

  recordMiss() {
    this.misses++;
  }

  getHitRate() {
    const total = this.hits + this.misses;
    return total > 0 ? (this.hits / total) * 100 : 0;
  }

  report() {
    console.log(`Cache Hit Rate: ${this.getHitRate().toFixed(2)}%`);
    console.log(`Hits: ${this.hits}, Misses: ${this.misses}`);
  }
}

const cacheMetrics = new CacheMetrics();

// Track in cachedContractCall
const cached = contractCache.get(cacheKey);
if (cached) {
  cacheMetrics.recordHit();
  return cached;
} else {
  cacheMetrics.recordMiss();
  // ... fetch and cache
}

// Report periodically
setInterval(() => cacheMetrics.report(), 60000);

Performance Monitoring

Contract Performance Monitoring

1. Gas Usage Tracking

Monitor gas consumption in tests:

#[test]
fn test_contribute_gas_budget() {
    let env = Env::default();
    env.budget().reset_unlimited();
    
    // Setup
    let contract = create_contract(&env);
    let group_id = contract.create_group(1000, 30, 100);
    let member = Address::random(&env);
    contract.join_group(group_id, member.clone(), None);
    
    // Measure
    env.budget().reset_unlimited();
    let start_cpu = env.budget().cpu_instruction_cost();
    contract.contribute(group_id, member.clone(), 1000);
    let end_cpu = env.budget().cpu_instruction_cost();
    
    let gas_used = end_cpu - start_cpu;
    println!("Contribute gas: {}", gas_used);
    
    // Assert within budget
    assert!(gas_used < 2_500_000, "Gas usage too high: {}", gas_used);
}

Production gas monitoring:

// Log gas metrics in contract
pub fn contribute(env: &Env, group_id: u64, member: Address, amount: i128) {
    let start = env.budget().cpu_instruction_cost();
    
    // ... contribution logic ...
    
    let end = env.budget().cpu_instruction_cost();
    let gas_used = end - start;
    
    // Log for monitoring
    log!(&env, "contribute: group={}, gas={}", group_id, gas_used);
}

Track gas trends over time:

# Run benchmarks and capture results
cargo test --manifest-path contracts/stellar-save/Cargo.toml benchmark -- --nocapture > gas_results.txt

# Extract and store metrics
grep "Gas used:" gas_results.txt | awk '{print $NF}' >> performance-results/gas-trends.json

2. Storage Cost Tracking

Analyze storage usage:

pub fn get_storage_stats(env: &Env, group_id: u64) -> StorageStats {
    let group_key = DataKey::Group(group_id);
    let members_key = DataKey::Members(group_id);
    
    // Count storage entries
    let mut entry_count = 0u32;
    let mut total_bytes = 0u64;
    
    // Estimate from known structures
    entry_count += 1; // group
    total_bytes += 256; // Group struct
    
    // Members list
    let members = env.storage().persistent().get::<_, Vec<Address>>(&members_key);
    if let Ok(members_vec) = members {
        entry_count += 1;
        total_bytes += (members_vec.len() as u64) * 32; // Address = 32 bytes
    }
    
    StorageStats {
        total_entries: entry_count,
        total_bytes,
        cost_estimate: calculate_storage_cost(total_bytes)
    }
}

fn calculate_storage_cost(bytes: u64) -> i128 {
    // Stellar storage pricing: ~0.00001 XLM per byte per ledger
    (bytes as i128) * 10_000 / 1_000_000_000
}

Compare storage approaches:

#[test]
fn test_storage_comparison() {
    let env = Env::default();
    
    // Traditional approach: separate entries per member
    let traditional_cost = {
        let mut cost = 0u64;
        for i in 0..100 {
            env.storage().persistent().set(&format!("member_{}", i), &true);
            cost += 1;
        }
        cost
    };
    
    // Optimized approach: bitmap
    let optimized_cost = {
        let bitmap = vec![true; 100];
        env.storage().persistent().set(&"bitmap", &bitmap);
        1u64
    };
    
    println!("Traditional: {} entries", traditional_cost);
    println!("Optimized: {} entries", optimized_cost);
    println!("Savings: {}%", ((traditional_cost - optimized_cost) * 100) / traditional_cost);
}

Frontend Performance Monitoring

1. Web Vitals Monitoring

Implement comprehensive monitoring:

import { getCLS, getFID, getFCP, getLCP, getTTFB, getINP } from 'web-vitals';

const sendMetricToAnalytics = (metric) => {
  // Send to your analytics service
  const body = JSON.stringify(metric);
  
  // Use sendBeacon for reliability
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/metrics', body);
  } else {
    fetch('/api/metrics', { method: 'POST', body });
  }
  
  // Also log locally
  console.log(`${metric.name}: ${metric.value}ms`);
};

// Measure all Web Vitals
getCLS(sendMetricToAnalytics);
getFID(sendMetricToAnalytics);
getFCP(sendMetricToAnalytics);
getLCP(sendMetricToAnalytics);
getTTFB(sendMetricToAnalytics);
getINP(sendMetricToAnalytics);

Track metrics over time:

// Store metrics in IndexedDB for historical analysis
const storeMetric = async (metric) => {
  const db = await openDB('stellar-save-metrics');
  const tx = db.transaction('metrics', 'readwrite');
  await tx.store.add({
    name: metric.name,
    value: metric.value,
    timestamp: Date.now(),
    url: window.location.href
  });
};

// Query historical data
const getMetricTrend = async (metricName, days = 7) => {
  const db = await openDB('stellar-save-metrics');
  const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
  
  const allMetrics = await db.getAll('metrics');
  return allMetrics.filter(m => 
    m.name === metricName && m.timestamp > cutoff
  );
};

2. Custom Performance Metrics

Track contract call duration:

const measureContractCall = async (operation, fn) => {
  const start = performance.now();
  const startMark = `${operation}-start`;
  const endMark = `${operation}-end`;
  
  performance.mark(startMark);
  
  try {
    const result = await fn();
    const duration = performance.now() - start;
    
    performance.mark(endMark);
    performance.measure(operation, startMark, endMark);
    
    // Log metric
    console.log(`${operation}: ${duration.toFixed(2)}ms`);
    
    // Send to monitoring service
    analytics.track('contract_call', {
      operation,
      duration: Math.round(duration),
      success: true
    });
    
    return result;
  } catch (error) {
    const duration = performance.now() - start;
    
    analytics.track('contract_call', {
      operation,
      duration: Math.round(duration),
      success: false,
      error: error.message
    });
    
    throw error;
  }
};

// Usage
const group = await measureContractCall('get_group', () =>
  contract.get_group(groupId)
);

const result = await measureContractCall('contribute', () =>
  contract.contribute(groupId, amount)
);

Measure component render time:

import { Profiler } from 'react';

const onRenderCallback = (
  id,           // Component name
  phase,        // "mount" or "update"
  actualDuration,
  baseDuration,
  startTime,
  commitTime
) => {
  console.log(`${id} (${phase}): ${actualDuration.toFixed(2)}ms`);
  
  // Alert if render is slow
  if (actualDuration > 1000) {
    console.warn(`Slow render detected: ${id} took ${actualDuration}ms`);
  }
};

export const ProfiledGroupList = () => (
  <Profiler id="GroupList" onRender={onRenderCallback}>
    <GroupList />
  </Profiler>
);

3. Network Performance

Monitor RPC latency:

const monitorRPCLatency = async (rpcCall, operationName) => {
  const start = Date.now();
  
  try {
    const result = await rpcCall();
    const latency = Date.now() - start;
    
    // Track latency
    analytics.track('rpc_call', {
      operation: operationName,
      latency,
      success: true
    });
    
    // Alert on slow calls
    if (latency > 3000) {
      console.warn(`Slow RPC call: ${operationName} took ${latency}ms`);
    }
    
    return result;
  } catch (error) {
    const latency = Date.now() - start;
    
    analytics.track('rpc_call', {
      operation: operationName,
      latency,
      success: false,
      error: error.message
    });
    
    throw error;
  }
};

// Usage
const group = await monitorRPCLatency(
  () => contract.get_group(groupId),
  'get_group'
);

Track request queue depth:

class RequestMonitor {
  constructor() {
    this.activeRequests = 0;
    this.maxConcurrent = 0;
  }

  async trackRequest(fn) {
    this.activeRequests++;
    this.maxConcurrent = Math.max(this.maxConcurrent, this.activeRequests);
    
    try {
      return await fn();
    } finally {
      this.activeRequests--;
    }
  }

  getStats() {
    return {
      activeRequests: this.activeRequests,
      maxConcurrent: this.maxConcurrent
    };
  }
}

const requestMonitor = new RequestMonitor();

// Usage
const result = await requestMonitor.trackRequest(() =>
  contract.get_group(groupId)
);

// Report periodically
setInterval(() => {
  const stats = requestMonitor.getStats();
  console.log(`Active requests: ${stats.activeRequests}, Max: ${stats.maxConcurrent}`);
}, 10000);

Monitoring Tools

1. Lighthouse CI

Run automated Lighthouse audits in CI/CD:

# Install
npm install -g @lhci/cli

# Run audit
lhci autorun --config .lighthouserc.json

Configuration:

{
  "ci": {
    "collect": {
      "numberOfRuns": 3,
      "url": ["http://localhost:4173"],
      "staticDistDir": "./dist"
    },
    "upload": {
      "target": "temporary-public-storage"
    },
    "assert": {
      "preset": "lighthouse:recommended",
      "assertions": {
        "categories:performance": ["error", {"minScore": 0.85}],
        "categories:accessibility": ["error", {"minScore": 0.90}],
        "first-contentful-paint": ["warn", {"maxNumericValue": 2000}],
        "largest-contentful-paint": ["warn", {"maxNumericValue": 2500}],
        "cumulative-layout-shift": ["warn", {"maxNumericValue": 0.1}]
      }
    }
  }
}

2. Performance Dashboard

Track metrics over time using automated dashboards:

Key metrics tracked:

  • Gas costs per function (contract)
  • Lighthouse scores (frontend)
  • Web Vitals (frontend)
  • Bundle sizes (frontend)
  • API response times (frontend)
  • Cache hit rates (frontend)

Dashboard setup:

# Generate performance report
./scripts/generate_performance_report.sh

# View in browser
open performance-report.html

3. Error Tracking

Integrate Sentry for production monitoring:

import * as Sentry from "@sentry/react";

Sentry.init({
  dsn: process.env.VITE_SENTRY_DSN,
  environment: process.env.VITE_ENVIRONMENT,
  tracesSampleRate: 0.1,
  integrations: [
    new Sentry.Replay({
      maskAllText: true,
      blockAllMedia: true
    })
  ]
});

// Wrap components
export const App = Sentry.withProfiler(AppComponent);

// Track errors
try {
  await contract.contribute(groupId, amount);
} catch (error) {
  Sentry.captureException(error, {
    tags: {
      operation: 'contribute',
      groupId
    }
  });
}

Benchmarking Instructions

Contract Benchmarking

1. Gas Benchmarks

Run all benchmarks:

cargo test --manifest-path contracts/stellar-save/Cargo.toml benchmark -- --nocapture

Run specific benchmark:

cargo test --manifest-path contracts/stellar-save/Cargo.toml benchmark_create_group_gas -- --nocapture

With detailed output:

RUST_BACKTRACE=1 cargo test --manifest-path contracts/stellar-save/Cargo.toml benchmark -- --nocapture --test-threads=1

2. Storage Benchmarks

Analyze storage usage:

# Run storage analysis
cargo test --manifest-path contracts/stellar-save/Cargo.toml test_storage_analysis -- --nocapture

# Compare traditional vs optimized
cargo test --manifest-path contracts/stellar-save/Cargo.toml test_storage_comparison -- --nocapture

Expected output:

Storage Analysis Report
========================
Members: 100
Cycles: 10

Traditional Approach: 1405 entries
Optimized Approach: 235 entries
Savings: 83%

3. Custom Benchmarks

Create custom benchmark:

#[test]
fn benchmark_custom_operation() {
    let env = Env::default();
    env.budget().reset_unlimited();
    
    // Setup
    let contract = create_contract(&env);
    
    // Measure
    let start = env.budget().cpu_instruction_cost();
    contract.custom_operation();
    let end = env.budget().cpu_instruction_cost();
    
    let gas_used = end - start;
    println!("Gas used: {}", gas_used);
    assert!(gas_used < TARGET_GAS);
}

Frontend Benchmarking

1. Lighthouse Audits

Run locally:

# Build production bundle
cd frontend
npm run build

# Start preview server
npm run preview -- --host 127.0.0.1 --port 4173

# In another terminal, run Lighthouse
npx lighthouse http://127.0.0.1:4173 --output html --output-path ./lighthouse-report.html

Run with CI configuration:

npx lhci autorun --config .lighthouserc-perf.json

2. Bundle Size Analysis

Analyze bundle:

# Install analyzer
npm install -D rollup-plugin-visualizer

# Build with analysis
npm run build -- --mode production

# View report
open stats.html

Check bundle sizes:

# List all chunks
ls -lh dist/assets/

# Check total size
du -sh dist/

Targets:

  • Main bundle: < 200KB (gzipped)
  • Vendor bundle: < 150KB (gzipped)
  • Total initial load: < 350KB (gzipped)

3. Runtime Performance

Profile React components:

import { Profiler } from 'react';

<Profiler id="GroupList" onRender={onRenderCallback}>
  <GroupList groups={groups} />
</Profiler>

function onRenderCallback(
  id, phase, actualDuration, baseDuration, startTime, commitTime
) {
  console.log(`${id} (${phase}) took ${actualDuration}ms`);
}

Measure render time:

import { useEffect } from 'react';

useEffect(() => {
  const start = performance.now();
  return () => {
    const duration = performance.now() - start;
    console.log(`Component mounted for ${duration}ms`);
  };
}, []);

Continuous Benchmarking

1. Automated CI Benchmarks

The project runs automated benchmarks on every PR and merge. See performance-benchmarking.md for details.

Workflow triggers:

  • On pull request
  • On push to main
  • Weekly scheduled runs

Outputs:

  • PR comments with results
  • Performance dashboard
  • Regression alerts

2. Local Benchmark Script

Run all benchmarks:

./scripts/run_benchmarks.sh

Script includes:

  • Contract gas benchmarks
  • Storage analysis
  • Frontend Lighthouse audit
  • Bundle size check

3. Regression Detection

Thresholds:

  • Gas increase > 10%: Warning
  • Lighthouse score decrease > 5 points: Warning
  • Bundle size increase > 20%: Warning

Response:

  • Review changes causing regression
  • Optimize if necessary
  • Document intentional increases

Benchmark Reporting

1. Generate Report

# Full performance report
./scripts/generate_performance_report.sh

# Output: performance-report.md

Report includes:

  • Gas costs for all functions
  • Storage usage analysis
  • Frontend metrics
  • Historical trends
  • Recommendations

2. Compare Branches

# Compare current branch to main
./scripts/compare_performance.sh main

# Output: performance-comparison.md

3. Track Over Time

Performance data is stored in performance-results/ and tracked in Git for historical analysis.

View trends:

# Show gas cost trends
cat performance-results/gas-trends.json

# Show Lighthouse trends
cat performance-results/lighthouse-trends.json

Performance Checklist

For Developers

Before submitting PR:

  • Run gas benchmarks locally
  • Check contract size (< 80KB warning, < 100KB limit)
  • Run Lighthouse audit (scores > 85)
  • Check bundle size (< 350KB initial load)
  • Profile critical paths
  • Review storage usage
  • Test on slow network (throttled)

Code review focus:

  • Unnecessary storage operations
  • Inefficient loops
  • Missing memoization
  • Large bundle imports
  • Unoptimized images
  • Missing caching

For Users

Creating groups:

  • Choose appropriate group size (< 100 recommended)
  • Set reasonable cycle duration
  • Consider gas costs in contribution amount

Contributing:

  • Contribute early in cycle
  • Monitor network congestion
  • Use recommended gas limits

Monitoring:

  • Check group performance metrics
  • Review transaction costs
  • Report performance issues

Additional Resources


Getting Help

Performance issues?

Found a performance bug?

  • Open an issue with benchmark results
  • Include reproduction steps
  • Provide profiling data if available


Frontend Bundle Budget

Overview

Stellar-Save enforces a per-chunk size limit of 100 KB (uncompressed) via Vite's chunkSizeWarningLimit. Breaching this limit emits a build warning and will fail CI once the bundle-size gate is added. The goal is to keep the initial load under 200 KB gzipped across all entry-point chunks.

Running the Analyzer

cd frontend
npm run build:analyze

This runs a production build and then opens frontend/dist/stats.html in your browser. The visualizer shows a treemap of every module included in the bundle, colour-coded by chunk.

Interpreting the Treemap

Colour / label Meaning
vendor-react React, ReactDOM, React Router — should be ≈ 50 KB gz
vendor-mui MUI core + Emotion — largest vendor chunk, ≈ 100–130 KB gz
vendor-stellar Stellar SDK + Freighter API — ≈ 80–100 KB gz
vendor-i18n i18next + react-i18next — ≈ 15 KB gz
route-analytics Analytics, platform analytics, group analytics pages
route-admin Admin feedback dashboard page
route-charts Recharts library — only loaded with chart-heavy routes
index (entry) App shell, router, layout, shared hooks

Red flags to look for:

  • The index entry chunk grows unexpectedly — a new import in App.tsx or AppRouter.tsx may have pulled in a heavy module synchronously.
  • A page module appears in the index chunk instead of its own route-* chunk — check that the import in routes.tsx uses lazy().
  • recharts appears outside route-charts — a page outside the analytics routes may be importing chart components directly.

Lazy-Loading Convention

All routes are registered through src/routing/routes.tsx using React.lazy. Never import a page component directly from AppRouter.tsx or a shared module — this defeats code splitting.

// ✅ Correct — creates a separate chunk
const AnalyticsDashboardPage = lazy(() => import('../pages/AnalyticsDashboardPage'));

// ❌ Wrong — page lands in the initial bundle
import AnalyticsDashboardPage from '../pages/AnalyticsDashboardPage';

Suspense fallbacks in AppRouter.tsx show a skeleton layout (not a blank screen) while the chunk downloads.

Budget Thresholds

Chunk Budget (uncompressed) Action on breach
index (entry) 200 KB Investigate new synchronous imports
Any vendor-* chunk 200 KB Review whether the full library is needed
Any route-* chunk 150 KB Check for unintended transitive imports
Individual page chunk 50 KB Lazy-load heavy sub-components

Run npm run build locally before opening a PR that adds new dependencies. Check the Vite output for (!) Some chunks are larger than 100 kB warnings.


Last Updated: June 2026
Maintained by: Stellar-Save Contributors