# 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`:

```sh
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>`:

```js
// 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.

```js
// 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 wrangler@4.153.0 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`:

```js
// 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.
