RISE Logo-Light

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:

  1. Grant permissions once (approve session key for 7 days)
  2. App signs transactions locally with the session key
  3. 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
  • hasSession checks: 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 function
    • mintInitial(): One-time RCT claim
    • faucet(): 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:

  1. Prepare Calls: RISE Wallet prepares the transaction and returns a digest
  2. Sign Locally: App signs digest with session private key (P256.sign)
  3. Send Prepared: RISE Wallet executes the pre-signed transaction
  4. 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:

  1. Create Session: Click button → One wallet popup → Key created
  2. Claim RCT: No popup, instant transaction
  3. Spin: No popups, completely gasless
  4. Check Permissions: Verify in wallet or on-chain
  5. 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.