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:
- Start watching for SpinResult events before sending the transaction
- Cache ALL events as they arrive (in a Map by requestId)
- After getting the requestId from the receipt, check if result is already cached
- If cached: return immediately (most common case)
- 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)addswatchShredsandwaitForTransactionReceiptmethods- 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:
- useRef for cache: Persists across renders but doesn't trigger re-renders
- watchShreds on mount: Starts listening immediately when page loads
- Event filtering:
- Check contract address matches
- Check event signature matches SpinResult (
0xc95f6b2ec3fb7d188682755102792da57a8ec34918faaab4b1b925f0c4556123)
- Decode and cache: Extract requestId, result, and payout, store in Map
- 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:
- Send spin transaction via session key (gasless)
- Wait for receipt from Shreds client (uses WebSocket, very fast)
- Extract requestId from SpinRequested event in the receipt
- Check cache first - 90%+ of the time, result is already there!
- 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 resultThe 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: 0x616d9204a230e2286a32eb3295c697372bd4cc093a7de2ef2455df4bffbc97c9Hardcode 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:
- Each result is keyed by unique requestId
- We only look up our specific requestId
- 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.