RISE Logo-Light

Event Watching

Set up Shreds for ultra-fast VRF result streaming with results caching

The Challenge: Ultra-Fast VRF

RISE VRF is incredibly fast - it fulfills randomness requests in 3-5 milliseconds. This creates a unique challenge:

The VRF result often arrives before your code finishes processing the transaction receipt! Traditional event watching patterns won't work.

The Solution: Results Caching

We'll use a pattern called results caching:

  1. Start watching for SpinResult events before sending the transaction
  2. Cache ALL events as they arrive (in a Map by requestId)
  3. After getting the requestId from the receipt, check if result is already cached
  4. If cached: return immediately (most common case)
  5. If not cached: poll the cache every 100ms until result arrives

This gives users a sub-millisecond experience - results feel instant!

Why This Works: Shreds uses WebSocket for real-time event streaming. Events arrive via push notification as soon as they're emitted, while receipt fetching is pull-based and slightly slower.

Shreds Overview

Shreds is RISE's real-time event streaming library. Instead of polling every second (like traditional eth_getLogs), Shreds:

  • Opens a WebSocket connection to RISE nodes
  • Receives events as soon as they're emitted (push-based)
  • Handles reconnection and error recovery automatically
  • Filters events efficiently on the server side

Implementation

Create Shreds Client

Add the Shreds client setup to your main page (src/app/page.tsx):

import { createPublicClient, webSocket, decodeEventLog } from 'viem'
import { riseTestnet } from 'viem/chains'
import { shredActions } from 'shreds/viem'
import { SLOT_MACHINE_ADDRESS, SLOT_MACHINE_ABI } from '@/constants'

// Create shreds client for event watching
const shredClient = createPublicClient({
  chain: riseTestnet,
  transport: webSocket('wss://testnet.riselabs.xyz/ws'),
}).extend(shredActions)

Key Points:

  • Uses WebSocket transport (wss://) not HTTP
  • .extend(shredActions) adds watchShreds and waitForTransactionReceipt methods
  • This client is for read-only operations (watching events, getting receipts)

Set Up Results Cache

Add global cache and watcher to your component:

export default function Home() {
  // ... existing state ...

  // Global results cache for SpinResult events
  const resultsCache = useRef(new Map<string, { result: [number, number, number], payout: bigint }>()).current

  // Start watching for SpinResult events on mount
  useEffect(() => {
    const unwatch = shredClient.watchShreds({
      includeStateChanges: false,
      onShred: (shred) => {
        shred.transactions.forEach((tx) => {
          const spinResultLogs = tx.logs.filter(
            (log) =>
              log.address.toLowerCase() === SLOT_MACHINE_ADDRESS.toLowerCase() &&
              log.topics[0] === '0xc95f6b2ec3fb7d188682755102792da57a8ec34918faaab4b1b925f0c4556123'
          )

          spinResultLogs.forEach((log) => {
            try {
              const decoded = decodeEventLog({
                abi: SLOT_MACHINE_ABI,
                data: log.data,
                topics: log.topics,
              })

              if (decoded.eventName === 'SpinResult') {
                const requestId = decoded.args.requestId.toString()
                const result = decoded.args.result as [number, number, number]
                const payout = decoded.args.payout as bigint

                console.log('📦 Cached SpinResult for requestId:', requestId, 'result:', result, 'payout:', payout.toString())
                resultsCache.set(requestId, { result, payout })
              }
            } catch (error) {
              console.error('Error decoding SpinResult:', error)
            }
          })
        })
      },
      onError: (error) => {
        console.error('Shred error:', error)
      },
    })

    return () => unwatch()
  }, [])

  // ... rest of component
}

What's Happening:

  1. useRef for cache: Persists across renders but doesn't trigger re-renders
  2. watchShreds on mount: Starts listening immediately when page loads
  3. Event filtering:
    • Check contract address matches
    • Check event signature matches SpinResult (0xc95f6b2ec3fb7d188682755102792da57a8ec34918faaab4b1b925f0c4556123)
  4. Decode and cache: Extract requestId, result, and payout, store in Map
  5. Cleanup on unmount: unwatch() stops the WebSocket connection

Event Signature: Use cast sig-event "SpinResult(uint256,uint8[3],uint256)" to get the event signature. The first topic (topics[0]) is always the event signature hash.

Implement Spin Handler with Cache Check

Now implement the spin function that uses the cache:

const handleSpin = async (): Promise<[number, number, number]> => {
  if (!hasSession) throw new Error("No session key")

  // Send the spin request (watcher is already running from useEffect)
  const hash = await sendWithSessionKey([{
    to: SLOT_MACHINE_ADDRESS,
    data: encodeFunctionData({ abi: SLOT_MACHINE_ABI, functionName: "spin", args: [] }),
  }])

  console.log('Spin transaction:', hash)

  // Wait for tx receipt to get requestId
  const receipt = await shredClient.waitForTransactionReceipt({ hash: hash as `0x${string}` })

  // Find SpinRequested event to extract requestId
  const requestedLog = receipt.logs.find(log =>
    log.address.toLowerCase() === SLOT_MACHINE_ADDRESS.toLowerCase() &&
    log.topics[0] === '0x616d9204a230e2286a32eb3295c697372bd4cc093a7de2ef2455df4bffbc97c9' // SpinRequested
  )

  if (!requestedLog) throw new Error('SpinRequested event not found')

  const requestedEvent = decodeEventLog({
    abi: SLOT_MACHINE_ABI,
    data: requestedLog.data,
    topics: requestedLog.topics,
  })

  const myRequestId = requestedEvent.args.requestId.toString()
  console.log('📍 My requestId:', myRequestId)

  // Check if result already in cache (VERY likely!)
  if (resultsCache.has(myRequestId)) {
    console.log('✅ Result already in cache!')
    return resultsCache.get(myRequestId)!.result
  }

  // Wait for result to arrive (rare case - only if VRF was slower than receipt)
  const spinData = await new Promise<{ result: [number, number, number], payout: bigint }>((resolve, reject) => {
    const timeout = setTimeout(() => {
      reject(new Error('Timeout'))
    }, 10000)

    const checkInterval = setInterval(() => {
      if (resultsCache.has(myRequestId!)) {
        console.log('✅ Result arrived!')
        clearTimeout(timeout)
        clearInterval(checkInterval)
        resolve(resultsCache.get(myRequestId!)!)
      }
    }, 100) // Check every 100ms
  })

  return spinData.result
}

Flow Breakdown:

  1. Send spin transaction via session key (gasless)
  2. Wait for receipt from Shreds client (uses WebSocket, very fast)
  3. Extract requestId from SpinRequested event in the receipt
  4. Check cache first - 90%+ of the time, result is already there!
  5. Poll cache if needed - Check every 100ms for up to 10 seconds (rarely needed)

Understanding the Flow

Here's what actually happens in practice:

- User clicks SPIN
- Transaction sent via session key
- VRF fulfills, SpinResult event emitted
- Shreds WebSocket receives event, caches it
- waitForTransactionReceipt returns
- Extract requestId from receipt
- Check cache → Result is there! ✅
- UI updates with result

The result is cached at 3ms, but we don't know the requestId until 6ms. That's why we need the cache!

Without caching:

- User clicks SPIN
- Get receipt and requestId
- Start watching for SpinResult event
- Wait for event... (might have already missed it!)

Event Signatures Reference

You'll need these event signatures for filtering:

# Get SpinResult signature
cast sig-event "SpinResult(uint256,uint8[3],uint256)"
# Output: 0xc95f6b2ec3fb7d188682755102792da57a8ec34918faaab4b1b925f0c4556123

# Get SpinRequested signature
cast sig-event "SpinRequested(uint256,address)"
# Output: 0x616d9204a230e2286a32eb3295c697372bd4cc093a7de2ef2455df4bffbc97c9

Hardcode these in your filtering logic for better performance.

Advanced: Handling Multiple Users

The current implementation caches ALL SpinResult events from ALL users. This is fine because:

  1. Each result is keyed by unique requestId
  2. We only look up our specific requestId
  3. Old entries can be garbage collected periodically

If you want to clean up old cache entries:

// Clean cache entries older than 1 minute
const cleanCache = () => {
  const now = Date.now()
  const maxAge = 60000 // 1 minute

  // Store cache with timestamps
  resultsCache.forEach((value, key) => {
    if (now - value.timestamp > maxAge) {
      resultsCache.delete(key)
    }
  })
}

// Run cleanup every 30 seconds
useEffect(() => {
  const interval = setInterval(cleanCache, 30000)
  return () => clearInterval(interval)
}, [])

Debugging Tips

Check if Events Are Being Received

onShred: (shred) => {
  console.log('🔵 Shred received at', new Date().toISOString())
  console.log('  Transactions:', shred.transactions.length)

  shred.transactions.forEach((tx) => {
    console.log('  TX:', tx.hash)
    console.log('  Logs:', tx.logs.length)
  })

  // ... rest of handler
}

Log Cache State

// After caching
resultsCache.set(requestId, { result, payout })
console.log('Cache size:', resultsCache.size)
console.log('Cache keys:', Array.from(resultsCache.keys()))

Test with Delay

To verify polling works (when result isn't already cached):

// Artificially delay cache lookup
await new Promise(resolve => setTimeout(resolve, 100))
if (resultsCache.has(myRequestId)) {
  // ...
}

Next Steps

Event watching is complete! Next, we'll build the frontend UI with animated slot reels, win detection, and integrate everything together.