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.
| Level | What you verify | Trust | Use for |
|---|---|---|---|
| Identity | session token (address, username), or a wallet signature (address) | Cassette's signing key (JWKS) / the wallet's | saves, multiplayer, leaderboards |
| Access | licence or item balance in your storefront(s) | the chain only | gate the game, levels, downloads |
| Purchases | the Sold events in a transaction | the chain only | credit coins and consumables exactly once |
What you need
| Value | Where |
|---|---|
| Game id (decimal) | id in GET https://cassette.gg/v1/games/<slug> (also gameId in the play shell's welcome) |
| Storefronts, current first | storefront.address in cassette.json, then every previousStorefronts[].address: licences and items bought there still count |
| Licence listing id in each | storefront.license (and previousStorefronts[].license), or cast call <storefront> "listingIdOf(bytes32)(uint256)" $(cast keccak <slug>:licence) |
| Item listing ids | storefront.items in cassette.json ({"<sku>": "<listingId>"}) |
| A Polygon RPC | your 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 host | e.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:
| Part | Must be |
|---|---|
| header | alg 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) |
| signature | Ed25519 over <header>.<payload> with that key |
iss, aud | cassette, game:<game id> (tokens are scoped to one game) |
exp, nbf | not expired, not before (allow 30 s of clock skew) |
sub | the player's address, lowercase: key your data on it, never on the username |
un, sid, gid, cid | username (or null), session id, game id, chain id |
ent | an 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_sha3is 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 (viemverifyMessage) or the wallet'sisValidSignature.- Items: the same
balanceOfwith 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
recipientis this player (from section 2), and only once perkey(store it in the same database transaction as the credit). MaplistingIdto your sku withstorefront.itemsincassette.json. - Inside the refund window (
releaseAt, unix seconds) the buyer can still self-refund: credit what can't be taken back afterreleaseAt, or re-checksale(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), pointRPCat 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 claimsiss: "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.