Cassette

verify-ownership

Online page: https://cassette.gg/agents/verify-ownership.md. Fetch it again before you act on it; don't work from a saved copy. Start of every task: https://cassette.gg/agents/guide.md.

Honest model: code that runs in the browser can always be copied, so real protection = your own backend refuses to serve content, progress or multiplayer to players whose proof doesn't verify.

Two separate questions, two separate sources:

  • Who is this player? A Cassette session token (inside Cassette), the player's own wallet signature (anywhere), or your own login. Identity only.
  • May they play / what do they own? The developer's own storefront contract, read from the chain. Never decide access from a Cassette token or Cassette's API: the chain is the access decision.
LevelWhat you verifyTrustUse for
Identitysession token (address, username), or a wallet signature (address)Cassette's signing key (JWKS) / the wallet'ssaves, multiplayer, leaderboards
Accesslicence or item balance in your storefront(s)the chain onlygate the game, levels, downloads
Purchasesthe Sold events in a transactionthe chain onlycredit coins and consumables exactly once

What you need

ValueWhere
Game id (decimal)id in GET https://cassette.gg/v1/games/<slug> (also gameId in the play shell's welcome)
Storefronts, current firststorefront.address in cassette.json, then every previousStorefronts[].address: licences and items bought there still count
Licence listing id in eachstorefront.license (and previousStorefronts[].license), or cast call <storefront> "listingIdOf(bytes32)(uint256)" $(cast keccak <slug>:licence)
Item listing idsstorefront.items in cassette.json ({"<sku>": "<listingId>"})
A Polygon RPCyour own keyed RPC from any provider's free tier for production traffic; to start, a public one (https://polygon-bor-rpc.publicnode.com). Cassette supplies none for this
The game's hoste.g. blob-run.pages.dev: what a player's wallet signs

1. Does this wallet own it (the chain)

balanceOf(address account, uint256 id) on each storefront, with the licence's (or an item's) listing id; > 0 = owns it. A plain JSON-RPC eth_call:

SF=0x70C2B961957B0e36C0C9D394Ec5796BB028b20B4; PLAYER=0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC
curl -s https://polygon-bor-rpc.publicnode.com -H 'content-type: application/json' -d "{\"jsonrpc\":\"2.0\",\"id\":1,
  \"method\":\"eth_call\",\"params\":[{\"to\":\"$SF\",\"data\":\"$(cast calldata "balanceOf(address,uint256)" $PLAYER 1)\"},\"latest\"]}"
# {"jsonrpc":"2.0","id":1,"result":"0x0000000000000000000000000000000000000000000000000000000000000001"}   1 = owns it

The calldata is 0x00fdd58e + the address and the id, each as a 32-byte word. hasLicense(address,bytes32) with the game key (cast keccak <slug>) answers the same for every licence listing of the game in one call. Check the current storefront first, then the earlier ones. Cache answers for about a minute. A refund (by the buyer inside the window, or by the developer) burns the tokens, so a refunded licence reads 0.

2. Who the player is

Inside Cassette: the session token. The game asks the play shell for it (bridge message getSessionToken, e.g. await (await CassettePaywall.host())?.request({ type: 'getSessionToken' }) → { token, expiresAt } or null; https://cassette.gg/agents/paywall.md) and sends it as Authorization: Bearer <token>. It lasts 1 hour; ask again when it expires. It's a JWS your server checks offline:

PartMust be
headeralg EdDSA (Ed25519), typ cassette-session+jwt, kid = a key in https://cassette.gg/.well-known/jwks.json (same as /v1/jwks; cache it 10 minutes, re-fetch on an unknown kid)
signatureEd25519 over <header>.<payload> with that key
iss, audcassette, game:<game id> (tokens are scoped to one game)
exp, nbfnot expired, not before (allow 30 s of clock skew)
subthe player's address, lowercase: key your data on it, never on the username
un, sid, gid, cidusername (or null), session id, game id, chain id
entan informational snapshot of what they own: never use it for access (section 1 is the decision)

Outside Cassette: the player's wallet. The game asks the player's wallet (EIP-1193) to sign one message per UTC day and sends x-cassette-wallet: <address>.<signature>:

// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Delegas Labs, S.L.
const [account] = await ethereum.request({ method: 'eth_requestAccounts' })
const msg = `${location.host} asks to check which games this wallet owns (${new Date().toISOString().slice(0, 10)}).`
const hex = '0x' + [...new TextEncoder().encode(msg)].map((b) => b.toString(16).padStart(2, '0')).join('')
const headers = { 'x-cassette-wallet': `${account}.${await ethereum.request({ method: 'personal_sign', params: [hex, account] })}` }

Your server accepts it for its own host and today's or yesterday's date (UTC), and recovers the signer (EIP-191).

3. One gate, no dependencies

A Cloudflare Worker (any runtime with fetch and WebCrypto works the same: Node 20+, Deno, Bun). GET /access → 200 {ok, account} for an owner of the licence, 401 no-identity, 403 no-licence. Adapt the constants and serve what you protect (levels, saves, a signed URL to paid assets) where it says so.

// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Delegas Labs, S.L.
const API = 'https://cassette.gg'                                   // only for Cassette's public signing keys
const GAME_ID = '7'                                         // GET /v1/games/<slug> -> id
const DOMAIN = 'blob-run.pages.dev'                         // the game's host (what wallets sign)
const RPC = 'https://polygon-bor-rpc.publicnode.com'        // better: your own keyed Polygon RPC
const STOREFRONTS = [['0x70C2B961957B0e36C0C9D394Ec5796BB028b20B4', 1n]]   // [storefront, licence listing id], current first

const rpc = async (method, params) => {
  const r = await (await fetch(RPC, { method: 'POST', headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) })).json()
  if (r.error) throw new Error(r.error.message)
  return r.result
}
const word = (x) => BigInt(x).toString(16).padStart(64, '0')
const hexOf = (bytes) => '0x' + [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
const b64u = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0))
const part = (s) => JSON.parse(new TextDecoder().decode(b64u(s)))

let jwks = { at: 0, keys: [] }
async function sessionAccount(token) {                      // Cassette session token -> address | null
  const [h, p, s] = token.split('.')
  const head = part(h), c = part(p), now = Date.now() / 1000
  if (head.alg !== 'EdDSA' || head.typ !== 'cassette-session+jwt') return null
  const find = () => jwks.keys.find((k) => k.kid === head.kid)
  if ((!find() && Date.now() - jwks.at > 30_000) || Date.now() - jwks.at > 600_000)
    jwks = { at: Date.now(), keys: (await (await fetch(`${API}/.well-known/jwks.json`)).json()).keys }
  const jwk = find()
  if (!jwk) return null
  const key = await crypto.subtle.importKey('jwk', { kty: jwk.kty, crv: jwk.crv, x: jwk.x }, { name: 'Ed25519' }, false, ['verify'])
  if (!(await crypto.subtle.verify('Ed25519', key, b64u(s), new TextEncoder().encode(`${h}.${p}`)))) return null
  if (c.iss !== 'cassette' || c.aud !== `game:${GAME_ID}` || c.exp < now - 30 || (c.nbf ?? 0) > now + 30) return null
  return c.sub
}

async function walletAccount(header) {                     // x-cassette-wallet: <address>.<signature> -> address | null
  const m = /^(0x[0-9a-fA-F]{40})\.0x([0-9a-fA-F]{130})$/.exec(header ?? '')
  if (!m) return null
  for (const t of [Date.now(), Date.now() - 86_400_000]) {
    const msg = new TextEncoder().encode(`${DOMAIN} asks to check which games this wallet owns (${new Date(t).toISOString().slice(0, 10)}).`)
    const hash = await rpc('web3_sha3', [hexOf([...new TextEncoder().encode(`\x19Ethereum Signed Message:\n${msg.length}`), ...msg])])
    const v = parseInt(m[2].slice(128), 16)
    const signer = await rpc('eth_call', [{ to: '0x0000000000000000000000000000000000000001',     // ecrecover
      data: hash + word(v < 27 ? v + 27 : v) + m[2].slice(0, 128) }, 'latest'])
    if (signer && signer !== '0x' && BigInt(signer) === BigInt(m[1])) return m[1].toLowerCase()
  }
  return null
}

async function ownsLicence(account) {                       // balanceOf(account, licence id) > 0 in any storefront
  for (const [sf, id] of STOREFRONTS)
    if (BigInt(await rpc('eth_call', [{ to: sf, data: '0x00fdd58e' + word(account) + word(id) }, 'latest'])) > 0n) return true
  return false
}

const cache = new Map()                                     // account -> { ok, at }
export default {
  async fetch(req) {
    const cors = { 'access-control-allow-origin': `https://${DOMAIN}`, 'access-control-allow-headers': 'authorization, x-cassette-wallet' }
    if (req.method === 'OPTIONS') return new Response(null, { headers: cors })
    const bearer = /^Bearer (\S+)$/.exec(req.headers.get('authorization') ?? '')?.[1]
    const account = (bearer && (await sessionAccount(bearer).catch(() => null)))
      || (await walletAccount(req.headers.get('x-cassette-wallet')).catch(() => null))
    if (!account) return Response.json({ error: { code: 'no-identity' } }, { status: 401, headers: cors })
    let c = cache.get(account)
    if (!c || Date.now() - c.at > 60_000) cache.set(account, (c = { ok: await ownsLicence(account), at: Date.now() }))
    if (!c.ok) return Response.json({ error: { code: 'no-licence' } }, { status: 403, headers: cors })
    return Response.json({ ok: true, account }, { headers: cors })    // serve what you protect here
  },
}
  • Deploy it in the developer's own account (npx -y [email protected] deploy worker.js --name blob-run-verifier --compatibility-date 2026-10-01, the same Cloudflare login as hosting, https://cassette.gg/agents/host-game.md). Cassette's API is never asked whether a player may play.
  • web3_sha3 is the RPC's keccak-256; if your provider lacks it, use any keccak-256 library. Smart-contract wallets (EIP-1271/6492) sign differently: verify those with a library (viem verifyMessage) or the wallet's isValidSignature.
  • Items: the same balanceOf with the item's listing id (sum it across your storefronts).

4. Credit a purchase once

After a purchase the game sends the transaction hash to your backend. Read its Sold events from your storefronts and credit each sale once, keyed by storefront + saleId:

// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Delegas Labs, S.L.
const SOLD = '0xb7268ef05bad665377005a214a960dd491d0b55a843fbdfc505cf3f0b99c3cb4'
// = cast keccak "Sold(uint256,uint256,address,address,uint64,uint256,uint256,address,uint64,bytes32,bytes32,bytes32)"
async function salesIn(txHash) {
  const r = await rpc('eth_getTransactionReceipt', [txHash])
  if (r?.status !== '0x1') return []                       // not mined yet, or failed: grant nothing
  const mine = new Set(STOREFRONTS.map(([sf]) => sf.toLowerCase()))
  return r.logs.filter((l) => l.topics[0] === SOLD && mine.has(l.address.toLowerCase())).map((l) => {
    const w = (i) => '0x' + l.data.slice(2 + 64 * i, 66 + 64 * i)   // recipient, qty, amount, fee, referrer, releaseAt, termsHash, sellerRecordHash, ref
    return { key: `${l.address.toLowerCase()}:${BigInt(l.topics[1])}`, listingId: BigInt(l.topics[2]),
      buyer: '0x' + l.topics[3].slice(26), recipient: '0x' + w(0).slice(26), qty: BigInt(w(1)), amount: BigInt(w(2)),
      releaseAt: Number(w(5)), consent: (parseInt(w(8).slice(2, 4), 16) & 1) === 1 }
  })
}
  • Credit only sales whose recipient is this player (from section 2), and only once per key (store it in the same database transaction as the credit). Map listingId to your sku with storefront.items in cassette.json.
  • Inside the refund window (releaseAt, unix seconds) the buyer can still self-refund: credit what can't be taken back after releaseAt, or re-check sale(saleId) (status 3 = refunded; https://cassette.gg/agents/handle-refunds.md) and remove the credit.
  • consent: the buyer ticked the immediate-delivery consent at your paywall (business sellers keep it as proof).
  • For display, the API has the receipt: GET https://cassette.gg/v1/receipts/tx/<txHash>. Credit from the chain, not from it.

Testing

  • On a test platform (GET https://cassette.gg/v1/config → chainId, rpcUrl), point RPC at its node and use its storefront.
  • Offline: generate an Ed25519 key, serve your own JWKS, and sign test tokens with header {"alg":"EdDSA","typ":"cassette-session+jwt","kid":"…"} and claims iss: "cassette", aud: "game:<id>", sub, exp.
  • A wallet header for tests: cast wallet sign --private-key <test key> "<host> asks to check which games this wallet owns (<YYYY-MM-DD>).".

Code in this document (and paywall.js) is MIT-licensed: SPDX-License-Identifier: MIT, Copyright (c) 2026 Delegas Labs, S.L. Copy and adapt it freely.