Session Keys
Implement gasless, popup-free gameplay with RISE Wallet session keys
What Are Session Keys?
Session keys are temporary signing keys that you grant limited permissions to. Instead of asking for wallet approval on every spin, users:
- Grant permissions once (approve session key for 7 days)
- App signs transactions locally with the session key
- All future spins are completely gasless and instant - no popups!
This is perfect for gaming where you want rapid, uninterrupted gameplay.
Security: Session keys have scoped permissions. You control exactly which functions they can call and how much they can spend. If stolen, attackers are limited to those permissions.
How Session Keys Work
┌─────────────┐
│ User │
│ (Wallet) │
└──────┬──────┘
│ 1. Grant permissions (ONE popup)
▼
┌─────────────────────────────────┐
│ On-chain Permission Registry │
│ • Public Key: 0xabc... │
│ • Can call: spin(), faucet() │
│ • Can spend: 10k RCT/hour │
│ • Expires: 7 days │
└─────────────────────────────────┘
│
│ 2. App stores private key in localStorage
▼
┌─────────────────────────────────┐
│ Frontend Application │
│ • Private key: localStorage │
│ • Signs all spins locally │
│ • NO MORE POPUPS! │
└─────────────────────────────────┘Implementation
Session Key State Management
Add session key state to your main page (src/app/page.tsx):
"use client"
import { useState, useEffect, useCallback, useRef } from 'react'
import { useConnection, useConnectors, useChainId } from 'wagmi'
import { Hex, P256, PublicKey, Signature } from 'ox'
import { Hooks } from 'rise-wallet/wagmi'
import { encodeFunctionData, parseEther } from 'viem'
import { SLOT_MACHINE_ADDRESS, RCT_TOKEN_ADDRESS, SLOT_MACHINE_ABI, RCT_ABI } from '@/constants'
// Storage key for session keys (per address)
function storageKey(address: string) {
return `rise_slots_${address}_session_key`
}
export default function Home() {
const { address, isConnected, connector } = useConnection()
const chainId = useChainId()
// Session key state
const [sessionPrivateKey, setSessionPrivateKey] = useState<string | null>(null)
const [sessionPublicKey, setSessionPublicKey] = useState<string | null>(null)
// RISE Wallet session key hooks
const grantPermissions = Hooks.useGrantPermissions()
const revokePermissions = Hooks.useRevokePermissions()
const { data: permissions, refetch: refetchPermissions } = Hooks.usePermissions()
// Find active session permission
const activePermission = permissions?.find(
(p) => sessionPublicKey && p.key.publicKey === sessionPublicKey && p.expiry > Math.floor(Date.now() / 1000)
)
const hasSession = !!activePermission && !!sessionPrivateKey
// Load session key from localStorage on mount
useEffect(() => {
if (!address) return
const pk = localStorage.getItem(storageKey(address))
if (pk) {
setSessionPrivateKey(pk)
setSessionPublicKey(PublicKey.toHex(P256.getPublicKey({ privateKey: pk as `0x${string}` }), { includePrefix: false }))
}
}, [address])
// Refetch permissions when connected
useEffect(() => {
if (isConnected && connector) {
refetchPermissions()
}
}, [isConnected, connector, refetchPermissions])
// ... rest of component
}Key Concepts:
- Session keys are P256 elliptic curve keys (not secp256k1 like Ethereum)
- Private key stored in localStorage (per address)
- Public key registered on-chain with permissions
hasSessionchecks: key exists + permissions exist + not expired
Create Session Key Function
Add the session creation function:
// Create session key
const createSession = async () => {
// Generate P256 key pair
const privateKey = P256.randomPrivateKey()
const publicKey = PublicKey.toHex(P256.getPublicKey({ privateKey }), { includePrefix: false })
// Grant on-chain permissions for spin(), mintInitial(), and faucet()
await grantPermissions.mutateAsync({
connector,
key: { publicKey, type: "p256" },
expiry: Math.floor(Date.now() / 1000) + (86400 * 7), // 7 days
feeToken: null,
permissions: {
calls: [
{ to: SLOT_MACHINE_ADDRESS, signature: "0xf0acd7d5" }, // spin()
{ to: RCT_TOKEN_ADDRESS, signature: "0x0d16e21a" }, // mintInitial()
{ to: RCT_TOKEN_ADDRESS, signature: "0xde5f72fd" }, // faucet()
],
spend: [
{ token: RCT_TOKEN_ADDRESS, limit: parseEther("10000"), period: "hour" },
],
},
})
localStorage.setItem(storageKey(address!), privateKey)
setSessionPrivateKey(privateKey)
setSessionPublicKey(publicKey)
refetchPermissions()
}Permission Breakdown:
-
calls: Which functions the session key can call
spin(): Main gameplay functionmintInitial(): One-time RCT claimfaucet(): Refill when broke
-
spend: Token spending limits
- Max 10,000 RCT per hour
- Prevents abuse if session key is compromised
-
expiry: Session key valid for 7 days
- Automatic expiration for security
- Users can revoke earlier if needed
Function Signatures: Use cast sig "functionName()" to get function signatures. For example: cast sig "spin()" returns 0xf0acd7d5.
Revoke Session Key Function
Add the ability to revoke sessions:
// Revoke session key
const revokeSession = async () => {
if (!activePermission) return
await revokePermissions.mutateAsync({ connector, id: activePermission.id })
localStorage.removeItem(storageKey(address!))
setSessionPrivateKey(null)
setSessionPublicKey(null)
refetchPermissions()
}This lets users revoke permissions if:
- They want to stop playing
- They suspect their session key was compromised
- They want to create a new session with different permissions
Send Transactions with Session Keys
Add the core function that signs transactions with session keys:
// Send transaction with session key
const sendWithSessionKey = useCallback(async (calls: { to: string; data: string }[]) => {
const privateKey = sessionPrivateKey! as `0x${string}`
const publicKey = sessionPublicKey!
const provider = (await connector!.getProvider()) as any
// Prepare the calls (get digest to sign)
const { digest, capabilities, ...request } = await provider.request({
method: "wallet_prepareCalls",
params: [{
calls,
chainId: Hex.fromNumber(chainId),
from: address,
atomicRequired: true, // All calls must succeed or all fail
key: { publicKey, type: "p256" },
}],
})
// Sign the digest with session private key
const signature = Signature.toHex(
P256.sign({ payload: digest as `0x${string}`, privateKey })
)
// Send the signed calls
const result = await provider.request({
method: "wallet_sendPreparedCalls",
params: [{ ...request, ...(capabilities ? { capabilities } : {}), signature }],
})
// Get transaction hash from result
const id = Array.isArray(result) ? result[0].id : result.id
const status = await provider.request({
method: "wallet_getCallsStatus",
params: [id],
})
if (status.status !== 200) {
throw new Error(`Call failed: status ${status.status}`)
}
return status.receipts[0].transactionHash
}, [address, chainId, connector, sessionPrivateKey, sessionPublicKey])Flow Explained:
- Prepare Calls: RISE Wallet prepares the transaction and returns a digest
- Sign Locally: App signs digest with session private key (P256.sign)
- Send Prepared: RISE Wallet executes the pre-signed transaction
- Get Receipt: Wait for transaction confirmation
This all happens without any wallet popups - signing is done locally with your session key!
Example: Claim RCT Function
Here's how to use session keys to claim RCT tokens:
const claimRCT = async () => {
if (!hasSession) throw new Error("No session key")
try {
setIsClaimingRCT(true)
const hash = await sendWithSessionKey([{
to: RCT_TOKEN_ADDRESS,
data: encodeFunctionData({ abi: RCT_ABI, functionName: "mintInitial", args: [] }),
}])
console.log('Claim RCT transaction:', hash)
// Wait a bit then refetch balances
await new Promise(resolve => setTimeout(resolve, 1000))
refetchRCT()
refetchHasMinted()
} finally {
setIsClaimingRCT(false)
}
}No Wallet Popup! The transaction is signed locally and executed instantly.
Example: Faucet Function
Similarly for the faucet:
const useFaucet = async () => {
if (!hasSession) throw new Error("No session key")
try {
setIsClaimingRCT(true)
const hash = await sendWithSessionKey([{
to: RCT_TOKEN_ADDRESS,
data: encodeFunctionData({ abi: RCT_ABI, functionName: "faucet", args: [] }),
}])
console.log('Faucet transaction:', hash)
await new Promise(resolve => setTimeout(resolve, 1000))
refetchRCT()
} finally {
setIsClaimingRCT(false)
}
}Security Considerations
Limited Scope
Session keys can only:
- Call specific functions (spin, mintInitial, faucet)
- Spend up to 10k RCT per hour
- Work for 7 days before expiring
They cannot:
- Transfer ETH
- Call other contracts
- Spend unlimited tokens
- Work after expiration
Client-Side Storage
Session private keys are stored in localStorage:
- ✅ Convenient for users (no re-authorization)
- ✅ Cleared when browser cache is cleared
- ⚠️ Lost if user clears browser data
Revocation
Users can revoke session keys at any time:
await revokePermissions.mutateAsync({ connector, id: activePermission.id })This removes on-chain permissions, making the session key useless even if someone still has the private key.
UI Integration
Show session key status to users:
{!hasSession ? (
<button onClick={createSession} disabled={grantPermissions.isPending}>
{grantPermissions.isPending ? "Creating Session..." : "Create Session Key to Play"}
</button>
) : (
<div>
<span>Session Active (Expires: {new Date(activePermission.expiry * 1000).toLocaleDateString()})</span>
<button onClick={revokeSession}>Revoke</button>
</div>
)}Testing Session Keys
Test the flow:
- Create Session: Click button → One wallet popup → Key created
- Claim RCT: No popup, instant transaction
- Spin: No popups, completely gasless
- Check Permissions: Verify in wallet or on-chain
- Revoke: Remove permissions, verify spins fail
Next Steps
Session keys are set up! Next, we'll implement event watching with Shreds to capture VRF results in real-time and build the results caching pattern for ultra-fast UX.