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.
<script src="paywall.js"></script>
<script>
// gated from the start: start the game only after this resolves
CassettePaywall.unlock().then(startGame)
</script>
// 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):
{"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.
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).
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:
eth_requestAccounts, theneth_chainId. If the chain is wrong, trywallet_switchEthereumChain; else answerwrong-chain.quote(id, qty, 0) <= maxTotal, the listing isactive,sold + qty <= supplywhensupply > 0.- A licence the recipient already holds: done (
ok), no transaction. termsCommitment(id, payer) == expect, elseTermsChanged.settlementToken()is native USDC, else stop withnot-a-storefront.- USDC
balanceOf(payer) >= total, elseinsufficient-funds(offer the top-up, section 5) before any signature. allowance(payer, storefront) >= total: sendbuy. Else ask for an EIP-2612 permit of exactlytotal(eth_signTypedData_v4) and sendbuyWithPermit. A wallet that can't sign it (error other than 4001):approve(storefront, total), wait for it, thenbuy. Code 4001 anywhere = cancelled.- Wait for the receipt and read the
Soldlog (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.
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):
{"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) |
{"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": "…"}}.
intentis a structured request, never calldata. Amounts are decimal strings in USDC units.recipient(a gift) is allowed only withstorefront-giftinfeatures.features:storefronthandles intents,top-uphandlestopUp,saveskeeps 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).useris the signed-in player (null: callsignInon Buy). The push{"event":{"type":"user","user":…}}updates it.- A game that says hello takes the session over: send
readyonce it's up,tracksession_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-upyet (cards are coming):featureswill not list it, so show the "Add at least <x> USDC" text.paywall.jsalready 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 againsttermsHash) and the refund policy (legal.refunds, checked against itssha256), 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.