# Paywall protocol

Cassette publishes no paywall package. A paid game sells through the developer's own storefront contract on Polygon,
and the game's paywall follows this protocol: read the chain, show the seller's terms, bind the purchase to what was
shown, pay through Cassette's wallet sheet or the player's wallet, hand over the order confirmation, then unlock.

**Do this:** copy `https://cassette.gg/agents/files/paywall.js` (`https://cassette.gg/agents/add-paywall.md`) next to the game's `index.html` and adapt it: texts, look,
where the demo ends. It is one file with no imports and no build step. It works on any host, and the rest of this page
is what it does.

```html
<script src="paywall.js"></script>
<script>
  // gated from the start: start the game only after this resolves
  CassettePaywall.unlock().then(startGame)
</script>
```

```js
// a free demo: where it ends (true = owned or just bought; false = "Not now", back to the menu, progress kept)
if (await CassettePaywall.unlock({ demo: true, dismissible: true })) nextLevel()
else showTitleScreen()
```

| `window.CassettePaywall` | Returns | |
|---|---|---|
| `unlock(opts?)` | `Promise<boolean>` | `true` when the player owns the licence, just bought it, or the game sells none. The overlay opens only when needed |
| `buy(sku, opts?)` | `Promise<boolean>` | an item; dismissible by default; a consumable is never "owned", so it always shows |
| `owns(sku?)` | `Promise<boolean>` | the licence (or `balanceOf > 0` for an item) from the chain; no UI. Rejects when the chain can't be read |
| `refund()` | `Promise<boolean>` | self-refunds the player's newest refundable purchase (inside Cassette the wallet sheet asks; with a plain wallet a `confirm()` first) |
| `saveConfirmation()` | `boolean` | downloads the last order confirmation again (call it from a click) |
| `configure(opts)` | | defaults for every call, e.g. `{ rpcUrl }` |
| `host()` | `Promise<{welcome, request(msg)} \| null>` | the Cassette shell, e.g. `request({type:'saves', op:'get', key})` |

Options: `demo` (`true` or a line about what comes next: the heading becomes "That's the end of the <title> demo"),
`title`, `dismissible` (shows "Not now"; default false for the licence, true for items), `sku`, `quantity`, `recipient`
(a gift), `accent` (CSS colour), and the config below. `unlock()` rejects only when the config is missing.

## 1. Configuration

The paywall reads `cassette.json` next to the page (options override it):

```json
{"slug": "blob-run", "title": "Blob Run",
 "storefront": {"chainId": 137, "address": "0x…", "game": "0x…", "license": "1", "items": {"gem": "2"}},
 "previousStorefronts": [{"address": "0x…"}]}
```

| Value | Rule |
|---|---|
| game key | `storefront.game`, else `keccak256(bytes(slug))`: `cast keccak "blob-run"` |
| licence listing id | `storefront.license`, else `listingIdOf(keccak256("<slug>:licence"))`, else the newest active licence in `listingsOfGame(game)`. None: free to play |
| item listing id | `storefront.items[sku]`, else `listingIdOf(sku as bytes32)`: the ASCII sku left-aligned, e.g. `gem` = `0x67656d00…00` (`cast to-bytes32 $(cast from-utf8 gem)`). Sku: `^[a-z0-9][a-z0-9._-]{0,30}$` |
| owned | `hasLicense(player, game)` (items: `balanceOf`) on the storefront **or any of `previousStorefronts`** |
| **game id (required)** | The game's numeric Cassette id (`GET https://cassette.gg/v1/games/<slug>` → `id`), validated `^[1-9][0-9]*$`. From the `gameId` option, else `<meta name="cassette-game" content="<id>">`, else `?cassette_game=`, else inside Cassette the Welcome's `gameId`. Missing: look it up on Cassette's public API (`<web>/v1/games/<slug>` with the slug above, else the game named by the storefront's listings in `<web>/v1/storefronts/<address>`, else `<web>/v1/games/by-url?url=<page URL>`), before any click. Still none: log the fix, show "This game isn't linked to its Cassette listing (its game id is missing), so Cassette sign-in can't open." and never open `/connect` without an id. A malformed meta tag or option is an error, not a fallback |
| web | Cassette's site, `webUrl` option or `?cassette_web=`, else `https://cassette.gg` (local fork: `http://<host>:8600`): the sign-in popup and the public API |
| RPC | `rpcUrl`, else on 137 `https://polygon-bor-rpc.publicnode.com`, `https://polygon.drpc.org`, `https://1rpc.io/matic` in order; on the local fork (31337) the `cassette_rpc` URL parameter |

**The seller record** names the seller. `sellerRecord()` returns `(string uri, bytes32 hash)`. Fetch the URI (https, or
`ar://<id>` at `https://arweave.net/<id>`, then `https://ar-io.dev/<id>`, then `https://ardrive.net/<id>`, no trailing
slash), else `/.well-known/cassette.json` at the build root (`/<id>/.well-known/cassette.json` for an Arweave build). Trust
it only when both hold, else name the seller by the storefront's `name()`, show no links and ask the consent anyway:

- `sha256(canonical JSON without claims and signature) == hash`. Canonical = keys sorted at every level, no whitespace:
  `jq -jcS 'del(.claims, .signature)' cassette.json | sha256sum`.
- `storefronts[]` contains `{"chainId": <chain>, "address": <this storefront>}`.

Fields used: `seller.{name, business, contact, legalName, address, email, vatNumber, taxId}`,
`legal.{terms, refunds, privacy, imprint, withdrawalForm}.{url, sha256}`.

## 2. Chain reads

All on the storefront unless noted; every read is a plain `eth_call` through a public RPC.

| Purpose | Solidity signature | Returns |
|---|---|---|
| licence id from the sku | `listingIdOf(bytes32 sku)` | `uint256` (0 = none) |
| a game's listings | `listingsOfGame(bytes32 game)` | `uint256[]` |
| the listing | `listing(uint256 id)` | `(uint8 kind, bytes32 game, bytes32 sku, uint256 price, uint64 supply, uint64 sold, bool transferable, bool consumable, bool active, string metadataURI, string termsURI, bytes32 termsHash)`; kind 0 = licence, 1 = item; price per unit in USDC units (6 decimals); `supply > 0` = a limited edition |
| the total | `quote(uint256 id, uint64 qty, address referrer)` | `(uint256 total, uint256 fee)` |
| ownership | `hasLicense(address account, bytes32 game)`, `balanceOf(address account, uint256 id)` | `bool`, `uint256` |
| the token | `settlementToken()` | `address`: must be native USDC `0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359` |
| the seller | `sellerRecord()`, `name()` | `(string uri, bytes32 hash)`, `string` |
| refund rules | `refundPolicy()`, `refundOverride(address buyer)` | `(uint32 window, uint16 maxSelfRefunds30d, address registry)`, `uint8` (0 none, 1 always allow, 2 never allow) |
| the commitment | `termsCommitment(uint256 id, address payer)` | `bytes32` |
| self-refund | `selfRefundEligibility(address buyer)`, `canSelfRefund(uint256 saleId)` | `(bool ok, uint8 reason, uint64 until)`, `(bool ok, uint8 reason)` |
| the player's sales | `salesOf(address buyer)`, `sale(uint256 saleId)`, `saleRefundTerms(uint256 saleId)` | `uint256[]`; `(uint256 listingId, address buyer, address recipient, uint64 qty, uint64 releaseAt, uint128 amount, uint128 fee, address feeTo, uint8 status)`, status 1 = escrowed; `(uint64 at, uint32 window, uint16 maxSelfRefunds30d, address registry)` |
| USDC | `balanceOf(address)`, `allowance(address owner, address spender)`, `nonces(address)` on USDC | `uint256` each |
| why restricted | `criteria()` on the registry | `(string uri, bytes32 hash)` |

`reason` (SelfRefundBlock): 0 none, 1 not escrowed, 2 window closed, 3 not the buyer, 4 denied by the seller, 5 refund
cap, 6 abuse registry, 7 tokens gone, 8 gift, 9 limited supply.

```sh
SF=0x…           # the storefront (cassette.json storefront.address)
P=0x…            # the player
R="--rpc-url https://polygon-bor-rpc.publicnode.com"
cast call $SF "listingIdOf(bytes32)(uint256)" $(cast keccak "blob-run:licence") $R
cast call $SF "listingsOfGame(bytes32)(uint256[])" $(cast keccak "blob-run") $R
cast call $SF "listing(uint256)((uint8,bytes32,bytes32,uint256,uint64,uint64,bool,bool,bool,string,string,bytes32))" 1 $R
cast call $SF "quote(uint256,uint64,address)(uint256,uint256)" 1 1 0x0000000000000000000000000000000000000000 $R
cast call $SF "hasLicense(address,bytes32)(bool)" $P $(cast keccak "blob-run") $R
cast call $SF "balanceOf(address,uint256)(uint256)" $P 2 $R
cast call $SF "settlementToken()(address)" $R
cast call $SF "sellerRecord()(string,bytes32)" $R
cast call $SF "name()(string)" $R
cast call $SF "refundPolicy()(uint32,uint16,address)" $R
cast call $SF "refundOverride(address)(uint8)" $P $R
cast call $SF "termsCommitment(uint256,address)(bytes32)" 1 $P $R
cast call $SF "selfRefundEligibility(address)(bool,uint8,uint64)" $P $R
cast call $SF "canSelfRefund(uint256)(bool,uint8)" 7 $R
cast call $SF "salesOf(address)(uint256[])" $P $R
cast call $SF "sale(uint256)((uint256,address,address,uint64,uint64,uint128,uint128,address,uint8))" 7 $R
cast call $SF "saleRefundTerms(uint256)(uint64,uint32,uint16,address)" 7 $R
cast call 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359 "balanceOf(address)(uint256)" $P $R
cast call 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359 "allowance(address,address)(uint256)" $P $SF $R
cast call 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359 "nonces(address)(uint256)" $P $R
cast call 0x3e3a0F06DAF0eabBA67Cf71A8A067d23A4Ed2f96 "criteria()(string,bytes32)" $R
cast call 0x7273324D448E58EAaFAFfEB79e8E4DC9f74944aE "isStorefront(address)(bool)" $SF $R   # the factory
```

## 3. The terms commitment (`expect`)

Every purchase carries `expect`, the commitment to what the buyer was shown:

```
expect = keccak256(abi.encode(bytes32 termsHash, bytes32 sellerRecordHash, uint32 window,
                              uint16 maxSelfRefunds30d, address registry, uint8 override))
```

`termsHash` comes from the listing, `sellerRecordHash` from `sellerRecord()`, the next three from `refundPolicy()`, and
`override` from `refundOverride(payer)` (0 when nobody is signed in yet). Compute it **from the values the card
displays, never re-read at signing.** While nothing has changed it equals `termsCommitment(listingId, payer)`. If the seller
changes any of them before the transaction lands, the purchase reverts `TermsChanged` and nothing moves. Never send
`expect = 0` (it skips the check).

<!-- commitment-example {"termsHash":"0xabababababababababababababababababababababababababababababababab","sellerRecordHash":"0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd","window":21600,"maxSelfRefunds30d":3,"registry":"0x3e3a0F06DAF0eabBA67Cf71A8A067d23A4Ed2f96","override":0,"expect":"0x95a8139297494807e027309f4af63fc2a583bf11b329e5417b3a69ed4668b170"} -->
```sh
cast keccak $(cast abi-encode "f(bytes32,bytes32,uint32,uint16,address,uint8)" \
  0xabababababababababababababababababababababababababababababababab \
  0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd \
  21600 3 0x3e3a0F06DAF0eabBA67Cf71A8A067d23A4Ed2f96 0)
# 0x95a8139297494807e027309f4af63fc2a583bf11b329e5417b3a69ed4668b170
```

## 4. Buying

The card shows the price (`quote`), "Sold by <seller> (<legal name>) · business seller | private seller" (the legal name
from the verified seller record when it differs from the trading name: the trader's identity, visible before paying), the consent box (section 6),
the refund line (section 8) and links to the seller's documents. It computes `expect` from those values. On Buy it
re-quotes, and stops with "The price changed" if the total is now above the one shown. The shown total goes out as
`maxPrice` / `maxTotal`.

**Path A, inside Cassette** (the Welcome lists `storefront` in `features`): send the storefront intent (section 5). The
wallet sheet checks `isStorefront`, re-reads the listing, compares its own commitment with `expect`, sets the referrer,
shows the charge and signs. Without `storefront-gift` in `features`, a gift is answered `unsupported`.

**Path B, the player's own wallet** (EIP-1193 `window.ethereum`, outside Cassette). Check in this order, and stop
before the wallet is asked for anything:

1. `eth_requestAccounts`, then `eth_chainId`. If the chain is wrong, try `wallet_switchEthereumChain`; else answer `wrong-chain`.
2. `quote(id, qty, 0) <= maxTotal`, the listing is `active`, `sold + qty <= supply` when `supply > 0`.
3. A licence the recipient already holds: done (`ok`), no transaction.
4. `termsCommitment(id, payer) == expect`, else `TermsChanged`.
5. `settlementToken()` is native USDC, else stop with `not-a-storefront`.
6. USDC `balanceOf(payer) >= total`, else `insufficient-funds` (offer the top-up, section 5) before any signature.
7. `allowance(payer, storefront) >= total`: send `buy`. Else ask for an EIP-2612 permit of exactly `total`
   (`eth_signTypedData_v4`) and send `buyWithPermit`. A wallet that can't sign it (error other than 4001):
   `approve(storefront, total)`, wait for it, then `buy`. Code 4001 anywhere = cancelled.
8. Wait for the receipt and read the `Sold` log (the receipt).

```
buy(uint256 listingId, uint64 qty, address recipient, uint256 maxTotal, address referrer, bytes32 ref, bytes32 expect) returns (uint256 saleId)
buyWithPermit(uint256 listingId, uint64 qty, address recipient, uint256 maxTotal, address referrer, bytes32 ref, bytes32 expect, uint256 deadline, bytes signature) returns (uint256 saleId)
event Sold(uint256 indexed saleId, uint256 indexed listingId, address indexed buyer, address recipient, uint64 qty, uint256 amount, uint256 fee, address referrer, uint64 releaseAt, bytes32 termsHash, bytes32 sellerRecordHash, bytes32 ref)
```

`referrer` = `0x0` outside Cassette (no fee); the sheet sets Cassette's own. `recipient` = the payer, or a gift's wallet.
`Sold` topic0 = `cast sig-event "Sold(uint256,uint256,address,address,uint64,uint256,uint256,address,uint64,bytes32,bytes32,bytes32)"`
= `0xb7268ef05bad665377005a214a960dd491d0b55a843fbdfc505cf3f0b99c3cb4`; data words: recipient, qty, amount, fee,
referrer, releaseAt, termsHash, sellerRecordHash, ref.

```sh
cast calldata "buy(uint256,uint64,address,uint256,address,bytes32,bytes32)" 1 1 $P 4990000 0x0000000000000000000000000000000000000000 $REF $EXPECT
```

The permit (USDC's domain is fixed: "USD Coin", version "2"; the deadline is the chain's latest block time + 30 min):

```json
{"types": {"EIP712Domain": [{"name": "name", "type": "string"}, {"name": "version", "type": "string"},
                            {"name": "chainId", "type": "uint256"}, {"name": "verifyingContract", "type": "address"}],
           "Permit": [{"name": "owner", "type": "address"}, {"name": "spender", "type": "address"},
                      {"name": "value", "type": "uint256"}, {"name": "nonce", "type": "uint256"},
                      {"name": "deadline", "type": "uint256"}]},
 "primaryType": "Permit",
 "domain": {"name": "USD Coin", "version": "2", "chainId": 137, "verifyingContract": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359"},
 "message": {"owner": "<payer>", "spender": "<storefront>", "value": "4990000", "nonce": "<USDC nonces(payer)>", "deadline": "1793000000"}}
```

**Which path:** inside Cassette, A. Outside, **C (Cassette) is the default whenever the game id resolves, even when
the browser has a wallet**: it pays through Cassette's wallet sheet, and Cassette accounts pay no network fee. The
card then offers "Pay with my own wallet" (B, the wallet pays its own fee); from B it offers "Pay with Cassette". B
alone only without a game id.

**Path C, Cassette sign-in outside Cassette.** On the player's click, open
`<web>/connect?game=<game id>` (`popup,width=440,height=680`); the game id must already be resolved (section 1), so the
popup opens inside the click (browsers block late popups). Say hello to that window from `<web>` exactly as to the
shell (section 5); its Welcome has `mode: "standalone"`, and the storefront intent, `signIn` and `topUp` work as in
path A: the intent carries only the purchase details, and Cassette's sheet builds the routed, sponsored call. It
serves only the game's registered play origin. No game id: no popup, the setup error instead.

**After a purchase (every path).** The buyer is the result's `account` (the wallet that paid), even when the popup has
closed since. Keep it for the tab's session (`sessionStorage`; drop it on a `user: null` push), so closing the popup or
reloading never turns the buyer back into a guest. Then read the licence again on chain for that account, up to 10
times a second apart: a public RPC can be a few blocks behind. The purchase's own receipt (its `Sold` event with that
`recipient`) also proves it. Unlock on either one. If neither shows it yet, say "You paid" and offer only "Check
again", never a second Buy.

## 5. Bridge messages (inside Cassette)

The game runs in an iframe of Cassette's play shell, which adds `?cassette_host=<shell origin>&cassette_game=<id>` to
its URL (and `cassette_rpc` on the local fork). Every message is `{"cassette": 1, "id": "<uuid>", "type": …}`. Each
request gets one reply, `{"cassette": 1, "re": "<id>", "ok": true, "result": …}` or `{"cassette": 1, "re": "<id>",
"ok": false, "error": "…"}`. Pushes are `{"cassette": 1, "event": {…}}`.

**Origins.** Say hello only to `window.parent`, addressed to the `cassette_host` origin and the `document.referrer`
origin, and only when that origin is on the allowlist: `https://cassette.gg`, `https://www.cassette.gg`, and in dev any
localhost, private, LAN or Tailscale host on port 86xx. Resend every 250 ms until a Welcome comes. Then accept messages
only when `event.source === window.parent` and the origin is the one that answered.

| Request (game → shell) | Reply |
|---|---|
| `{"type":"hello","sdkVersion":"paywall.js/1"}` | Welcome (below) |
| `{"type":"ready"}` | `null` (hides the shell's loader) |
| `{"type":"track","events":[{"type":"session_start","t":1793000000000,"props":{"mode":"embedded"}}]}` | `null` |
| `{"type":"signIn"}` | `{"address":"0x…","username":"ana"}` or `null` |
| `{"type":"storefront","intent":{"action":"buy","chainId":137,"storefront":"0x…","listingId":"1","quantity":1,"maxPrice":"4990000","ref":"0x…","expect":"0x…"}}` | StorefrontTxResult |
| `{"type":"storefront","intent":{"action":"selfRefund","chainId":137,"storefront":"0x…","saleId":"7"}}` | StorefrontTxResult |
| `{"type":"topUp","chainId":137,"token":"<settlementToken>","account":"0x…","amount":"4990000"}` | `{"ok":true}` or `{"ok":false,"reason":"cancelled\|failed\|unsupported"}`, once the USDC arrived or the player gave up |
| `{"type":"saves","op":"get\|set\|remove\|list","key":"slot1","value":"…"}` | `string\|null`, `true`, `true`, `string[]` (keys 1-128 chars, values ≤ 1 MiB) |

```json
{"bridgeVersion": 1, "gameId": "7", "slug": "blob-run", "chainId": 137, "mode": "embedded",
 "user": {"address": "0x…", "username": "ana"}, "capabilities": ["phone"],
 "features": ["storefront", "top-up", "saves"], "intent": "buy"}
```

StorefrontTxResult: `{"ok": true, "txHash": "0x…", "account": "0x…"}`, or `{"ok": false, "reason": "cancelled |
failed | signed-out | not-a-storefront | wrong-chain | unsupported | insufficient-funds", "error": {"name":
"TermsChanged", "message": "…"}}`.

- `intent` is a structured request, never calldata. Amounts are decimal strings in USDC units. `recipient` (a gift) is
  allowed only with `storefront-gift` in `features`.
- `features`: `storefront` handles intents, `top-up` handles `topUp`, `saves` keeps saves per game and wallet across
  builds. A feature that's absent means "don't ask".
- `"intent": "buy"`: the player pressed "Buy full game" on the store page. Open the paywall at once (dismissible).
- `user` is the signed-in player (`null`: call `signIn` on Buy). The push `{"event":{"type":"user","user":…}}` updates it.
- A game that says hello takes the session over: send `ready` once it's up, `track` `session_start`, then
  `{"type":"heartbeat","t":…}` every 30 s while visible. Otherwise the shell stops counting the play session.
- Not enough USDC: show the missing amount and "Top up", then re-read the balance and buy normally, so the self-refund
  still applies. Without `top-up`, say "Add at least <x> USDC on Polygon to <wallet>, then check again."
- Cassette does not offer `top-up` yet (cards are coming): `features` will not list it, so show the "Add at least <x>
  USDC" text. `paywall.js` already switches on the feature, so nothing changes in the game when it ships.

## 6. The consent bit

A business seller (`seller.business` not `false`, or an unverified record) needs the buyer's immediate-delivery consent
before the sale. The checkbox reads exactly "Deliver it now. I understand I lose my 14-day right to cancel once it's
delivered.", and Buy stays disabled until it is ticked. Record it in the purchase: `ref` = 32 random bytes with
**bit 0 of the first byte** = consent (`ref[0] = (rand & 0xfe) | consent`). `Sold.ref` carries it, so the developer's
own sale event is the record. No server is involved.

## 7. The order confirmation (before play)

EU law (CRD arts. 8(7) and 16(m)) needs the seller's confirmation on a durable medium **before the game unlocks**. After
every paid sale, show "Your order confirmation", then "Paid <x> USDC to <seller> · sale <id>. Save the seller's confirmation
of your purchase (its terms and refund policy included) before you play." Offer **Save confirmation and play**
(download `<seller>-order-<saleId>.html`, then unlock) and a smaller **Play without saving**. A flag on the chain isn't
enough. The file is one self-contained HTML page with no scripts, built in the browser from the chain and the
seller's documents:

- the seller: name; for business sellers the legal name, address, `Email:`, `VAT number:`, `Tax ID:`; the contact for
  purchases and refunds; "Cassette is not the seller."
- the purchase: what (a licence to play <title>, or `<qty> × <sku> in <title>`), price paid ("final price, including any
  taxes"), date (`saleRefundTerms(saleId).at`, UTC), wallet, gift recipient, storefront, sale id, transaction.
- the consent sentence when bit 0 is set and the seller is a business.
- the refund rule: the window and its end, or gift, limited edition or no window, and how to write to the seller.
- the terms of sale (`listing.termsURI`, checked against `termsHash`) and the refund policy (`legal.refunds`, checked
  against its `sha256`), in full, with scripts, frames, forms and event handlers stripped. A mismatch or an unreadable
  document gets a visible warning.

## 8. Self-refund

The refund line under Buy (keep the wording):

| Case | Line |
|---|---|
| `window = 0` | No self-refund for this purchase. Contact the seller if something is wrong. |
| eligible | Changed your mind? Refund yourself within <6 hours> of buying. |
| `supply > 0` | A limited edition: no self-refund for this purchase. Contact the seller if something is wrong. |
| a gift | Gifts can't be self-refunded: only the seller can refund a gift. Contact the seller if something is wrong. |
| abuse registry | Self-refund isn't available for this wallet until <date> (why: <criteria uri>). You can still buy, and ask the seller for a refund. |
| refund cap | Self-refund isn't available for this wallet: it reached the seller's self-refund limit. You can still buy, and ask the seller for a refund. |
| denied by the seller | The seller has turned off self-refund for this wallet. You can still ask the seller for a refund. |

To refund, the **payer** calls `selfRefund(uint256 saleId)` before `releaseAt`. Inside Cassette, send the `selfRefund`
intent instead. It returns the full amount and burns what was bought (it must still be held). Find the sales with
`salesOf(payer)`, newest first. Keep those with status 1 (escrowed), `releaseAt > now`, `buyer == recipient` and
`canSelfRefund(saleId).ok`, and stop at a sale whose `releaseAt` is more than 14 days old. Gifts (payer ≠ recipient)
and limited editions (`supply > 0`) are never self-refundable; the seller's own `refund` still works.

## 9. Errors

Revert data starts with a 4-byte selector, `cast sig "<signature>"`. Wallet errors nest it under `data`, `data.data`
or `cause`. Show the message followed by "Nothing was charged."

| Selector | Error | Player message |
|---|---|---|
| `0x49dfc9c3` | `NotStorefront(address)` | That address is not a storefront created by the Cassette storefront factory. |
| `0xd2650cd1` | `ERC2771UntrustfulTarget(address,address)` | (the same) |
| `0x161bec97` | `ListingInactive(uint256)` | It is not on sale right now. |
| `0xc438d349` | `NoSuchListing(uint256)` | That item or licence is not listed. |
| `0xe65a964a` | `SupplyExhausted(uint256,uint64,uint64)` | Sold out. |
| `0xc352b8f6` | `AlreadyLicensed(uint256,address)` | This wallet already owns the game. |
| `0x3aa35a61` | `FreeClaimUsed(uint256,address)` | This wallet already claimed it. |
| `0x100d629e` | `PriceOverMax(uint256,uint256)` | The price went up since it was shown. |
| `0x39161c2b` | `PermitAmountMismatch(uint256,uint256)` | The payment authorisation didn't match the price. |
| `0x7864e479` | `BadQuantity(uint64)` | That quantity is not allowed. |
| `0xedc2f1c3` | `TermsChanged(bytes32,bytes32)` | The seller changed the terms, the seller details or the refund rules since they were shown. (Re-read and show the new terms.) |
| `0x4fb2cd08` | `SelfRefundUnavailable(uint256,uint8)` | This purchase can't be refunded from here. |
| `0x81fd8f27` | `AlreadyRefunded(uint256)` | That sale was already refunded. |

| `reason` | Player message |
|---|---|
| `cancelled` | Cancelled. Nothing was charged. |
| `signed-out` | Sign in to buy. |
| `wrong-chain` | Switch your wallet to Polygon and try again. |
| `insufficient-funds` | Not enough USDC in your wallet. Nothing was charged. (Re-read: the card says how much USDC to add, or shows Top up when the host offers it.) |
| `not-a-storefront` | This isn't a standard storefront, so the payment was stopped. Nothing was charged. |
| `failed` (no `error`) | The payment didn't go through. Nothing was charged. |

---

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.
