sell-items
Online page: https://cassette.gg/agents/sell-items.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.
The developer is the seller. Licences and items are listings in the developer's own storefront contract (an ERC-1155 collection on Polygon, created from Cassette's admin-less factory and owned by the human's wallet). Players pay that storefront in USDC from the game's own paywall: inside Cassette through the Cassette wallet sheet, elsewhere with their own browser wallet. Cassette doesn't sell anything, holds no role in the storefront and can't change it.
Money: each paid sale stays in the storefront (escrow) for the refund window the human chose (template 6 hours), during
which the buyer can refund themselves. After the window anyone can release it to the developer's payout wallet (https://cassette.gg/agents/handle-refunds.md). Cassette's referral fee is set in the developer's own storefront: 0% for now, and only on sales
Cassette referred.
Before this page: the seller's details, documents and storefront (https://cassette.gg/agents/publish-game.md, steps 3–7, and https://cassette.gg/agents/set-up-seller.md). Your key is an operator of the storefront (create added it), so it lists and updates without the
human. Protocol and shell variables ($API, $CFG, $RPC, $CHAIN, $ROUTER, $KEY, $ME):
https://cassette.gg/agents/protocol.md. SF = the storefront (storefront.address in cassette.json).
Option for a paid game with depth: a free demo, then about $5
Pricing is the human's choice, and many games are free. For a paid game with depth (story-driven, several levels or hours), one option to suggest: the start plays free, the paywall opens where the demo ends, and $4.99 unlocks the rest and the ending. Gating from the first second is just as valid.
- Where to cut: at a boundary the game already has (a chapter end, a level-complete screen, the return to a hub), never mid-level, mid-dialogue or mid-fight. Story or level-based: the end of chapter (or act) 1, or after level N where N is about 15–25% of the game; aim for 20–40 minutes for a first-time player. Open or systemic games (sandbox, roguelike, sim): after about 30–45 minutes, N runs or days, or when the second area opens (count play time in the game's saves so a reload doesn't reset it). Never before the game's best idea: the demo shows the core loop and the first real "aha".
- End on a hook: right after a cliffhanger, a reveal, a new ability, a boss door or a glimpse of a new area, not after a lull. Put what comes next in the paywall's line ("Chapter 2: the flooded city, and the truth about Mara."). After buying, the game continues from exactly that point with the same save, never a restart.
- Price: $4.99 for a few hours of play; $1.99–2.99 under about 2 hours; up to $9.99 for a long, polished game. The
human decides. Say "Free demo" in the tagline or description, and set
"demo": {"covers": "Chapter 1", "minutes": 30}incassette.jsonso the store tile says what's free.
A. Declare what's for sale in cassette.json
Paid game (licence): { "pricing": { "type": "paid", "priceUsd": "4.99", "licenseTransferable": false } }. One
licence per player; licenseTransferable (can the player resell it) defaults to the human's licenceTransferable
answer in seller.json.
Items: pricing free-with-items (or keep paid for a paid game with items) and an items array. An item sku is
permanent: lowercase a-z 0-9 . _ -, ≤ 31 chars, from the item's name (rainbow-trail).
{
"pricing": { "type": "free-with-items" },
"items": [
{ "sku": "neon-trail", "name": "Neon Trail", "description": "A glowing trail behind your ship.",
"priceUsd": "0.99", "maxSupply": 0, "transferable": true, "consumable": false, "image": "items/neon-trail.png" },
{ "sku": "coins-100", "name": "100 Coins", "priceUsd": "0.49", "consumable": true }
]
}
- Defaults:
maxSupply0(unlimited),transferabletrue,consumablefalse. Map the user's words: "permanent unlock/cosmetic" =consumable:false; "can be used up (coins, lives)" =consumable:true; "tradeable" =transferable:true, "can't be traded" =transferable:false. - A supply cap makes a limited edition, and limited editions have no self-refund (the storefront's rule; it stops
buyers from holding a drop "sold out" by buying and self-refunding). Ask the human before setting
maxSupply> 0: "A limited number means buyers can't refund it themselves; only you can refund it. Keep it limited?" The paywall tells buyers "no self-refund" before they pay. maxSupply,transferableandconsumablecan't change once listed: different terms need a new sku. Price and name can change; removing an item from the manifest takes it off sale (owners keep it).- Prices are USD with at most 2 decimals, sold as the same amount of USDC.
"priceUsd": "0"= a free claim, once per player.
B. The listings
Each licence and item is one listing; its id (1, 2, …) is also its ERC-1155 token id.
| Field | Licence | Item |
|---|---|---|
kind | 0 | 1 |
game | cast keccak <slug> | the same |
sku | cast keccak <slug>:licence | cast format-bytes32-string <sku> |
price | cast parse-units <priceUsd> 6 (4.99 → 4990000) | the same; 0 = a free claim |
supply | 0 | maxSupply |
transferable | licenseTransferable, else seller.json licenceTransferable | transferable |
consumable | false | consumable |
active | true | true |
metadataURI | "" | "" |
termsURI, termsHash | legal.terms.url and 0x + legal.terms.sha256 from the seller record (https://cassette.gg/agents/set-up-seller.md) | the same |
Functions (on $SF):
| Function | Who |
|---|---|
createListing((uint8,bytes32,bytes32,uint256,uint64,bool,bool,bool,string,string,bytes32)) | owner or operator |
updateListing(uint256 listingId, uint256 price, bool active, string metadataURI, string termsURI, bytes32 termsHash) | owner or operator |
listingIdOf(bytes32 sku) returns (uint256) (0 = not listed), listingsOfGame(bytes32 game) returns (uint256[]) | view |
listing(uint256) returns ((uint8 kind, bytes32 game, bytes32 sku, uint256 price, uint64 supply, uint64 sold, bool transferable, bool consumable, bool active, string metadataURI, string termsURI, bytes32 termsHash)) | view |
What to send, for each licence or item in cassette.json:
listingIdOf(sku)is 0 →createListing.- Otherwise read
listing(id): ifprice,active,metadataURI,termsURIortermsHashdiffer from what you want →updateListing(id, …). Ifkind,supply,transferableorconsumablediffer, they stay as listed: tell the human and use a new sku for the new terms. - Every id in
listingsOfGame(game)that is still active but no longer incassette.json→updateListing(id, price, false, metadataURI, termsURI, termsHash)with its current values: off sale, holders keep it.
Paid listings need a termsHash (else BadListing). New terms of sale (a re-adoption) = updateListing on every
listing with the new termsURI/termsHash.
GAME=$(cast keccak blob-run); SKU=$(cast format-bytes32-string neon-trail)
TERMS_URL=$(jq -r .legal.terms.url .well-known/cassette.json); TERMS_HASH=0x$(jq -r .legal.terms.sha256 .well-known/cassette.json)
cast call $SF "listingIdOf(bytes32)(uint256)" $SKU --rpc-url $RPC # 0: not listed yet
NEW=$(cast calldata "createListing((uint8,bytes32,bytes32,uint256,uint64,bool,bool,bool,string,string,bytes32))" \
"(1,$GAME,$SKU,$(cast parse-units 0.99 6),0,true,false,true,\"\",$TERMS_URL,$TERMS_HASH)")
ID=1 # an existing listing: read it, then change only what differs
cast call $SF "listing(uint256)((uint8,bytes32,bytes32,uint256,uint64,uint64,bool,bool,bool,string,string,bytes32))" $ID \
--rpc-url $RPC --json | jq -c '.[0] | {kind:.[0], price:.[3], supply:.[4], sold:.[5], active:.[8], termsURI:.[10], termsHash:.[11]}'
# {"kind":0,"price":"4990000","supply":0,"sold":3,"active":true,"termsURI":"https://blob-run.pages.dev/legal/terms-of-sale.html","termsHash":"0xf901…"}
UPD=$(cast calldata "updateListing(uint256,uint256,bool,string,string,bytes32)" $ID $(cast parse-units 5.99 6) true "" $TERMS_URL $TERMS_HASH)
In a cast tuple, put names and empty strings in escaped quotes (\"\"); an unquoted empty field doesn't parse. Any
library (viem encodeFunctionData, ethers Interface) gives the same bytes.
C. Send them
Your key relays them, gas-free (protocol.md §5 with the storefront router's domain), one call per request:
sf_relay() { # sf_relay <storefront> <calldata>: one call as your key, through the StorefrontRouter
local req
req=$(jq -n --arg from $ME --arg to "$1" --arg data "$2" --argjson chain $CHAIN --arg router $ROUTER \
--arg nonce "$(cast call $ROUTER "nonces(address)(uint256)" $ME --rpc-url $RPC | cut -d' ' -f1)" --argjson deadline $(( $(date +%s) + 900 )) '{
types: {
EIP712Domain: [{name:"name",type:"string"},{name:"version",type:"string"},{name:"chainId",type:"uint256"},{name:"verifyingContract",type:"address"}],
ForwardRequest: [{name:"from",type:"address"},{name:"to",type:"address"},{name:"value",type:"uint256"},{name:"gas",type:"uint256"},{name:"nonce",type:"uint256"},{name:"deadline",type:"uint48"},{name:"data",type:"bytes"}]},
primaryType: "ForwardRequest",
domain: {name:"CassetteStorefrontRouter", version:"1", chainId:$chain, verifyingContract:$router},
message: {from:$from, to:$to, value:"0", gas:"1000000", nonce:$nonce, deadline:$deadline, data:$data}}')
jq --arg sig "$(cast wallet sign --data "$req" --private-key $KEY)" \
'{request: ((.message | {from,to,value,gas,deadline,data}) + {signature: $sig}), forwarder: "storefront-router"}' <<<"$req" \
| curl -s -X POST $API/v1/relay -H 'content-type: application/json' -d @-
}
sf_relay $SF $NEW
# {"txHash":"0x…","status":"mined","blockNumber":…}
cast call $SF "listingIdOf(bytes32)(uint256)" $SKU --rpc-url $RPC # 2: its listing id
"status":"pending": pollGET $API/v1/relay/<txHash>untilmined. Send the next call only after the previous one is mined (each uses the next nonce).{"error":{"code":"relay_reverted","message":"… NotOperator …"}}: this key isn't an operator (e.g. a new agent key). Ask the human to sign instead (below), withsetOperator(<your key>, true)first so you can do the rest yourself.SkuInUse: already listed (another run got there first): readlistingIdOfand update instead.
Or the human signs them (protocol.md §6; at most 8 calls per link, one link after another for more):
OWNER=$(cast call $SF "owner()(address)" --rpc-url $RPC)
jq -n --argjson chain $CHAIN --arg me $ME --arg owner $OWNER --arg sf $SF --arg a "$NEW" --arg b "$UPD" \
'{v:1, chainId:$chain, purpose:"sync-listings", agent:$me, owner:$owner, calls:[{to:$sf,data:$a},{to:$sf,data:$b}],
summary:["List Neon Trail for 0.99 USDC","Licence: 5.99 USDC"]}' > req.json
echo "https://cassette.gg/studio/sign#$(jq -cj . req.json | base64 -w0 | tr '+/' '-_' | tr -d '=')"
Then record the ids in cassette.json, which the paywall reads from the developer's origin:
"storefront": {"chainId": 137, "address": "<SF>", "license": "1", "items": {"neon-trail": "2", "coins-100": "3"}}.
Deploy (https://cassette.gg/agents/host-game.md), upload the manifest and publishBuild (https://cassette.gg/agents/publish-game.md, step 9).
D. The paywall in the game
The paywall is a file you copy into the build and adapt: https://cassette.gg/agents/paywall.md (the protocol) and
https://cassette.gg/agents/files/paywall.js (the reference file it links). It reads prices and ownership from the developer's storefront
on chain; nothing asks a Cassette server whether the player may play.
- Free demo (an option for a game with depth): the game starts at once; where the demo ends, call the paywall (e.g.
if (await CassettePaywall.unlock({ demo: "<what comes next>", dismissible: true })) keepPlaying() else showMenu()). Owners pass at once; others see "That's the end of the demo", the price and "Not now" (back to the menu, progress kept). - Gated from the start: start the game only after the paywall resolves for an owner. Nothing else gates a storefront game: without it, anyone who opens the play URL plays for free.
- The paywall shows the price, the seller's name and terms, the refund window, "no self-refund" for a limited edition, and the immediate-delivery consent for business sellers. After each purchase it gives the buyer the seller's order confirmation (an HTML file with the terms and refund policy in full) before the game unlocks: leave that step in (EU law needs it).
- Not enough USDC: the player is told how much to add and where. When Cassette's card top-up ships, the host
offers Top up through the
top-upfeature and nothing in your game changes. Don't describe cards to the player today.
Items from the game's own buttons (paywall.md has the calls):
- Show the price and the seller's name next to the button, and link the seller's terms and refund policy.
- Consent: for a business seller (or a seller record that isn't verified), show an unticked checkbox "Deliver it
now. I understand I lose my 14-day right to cancel once it's delivered." and keep Buy disabled until the player ticks
it. Never pre-tick it. It's recorded in the sale itself (bit 0 of the sale's
ref), not on a Cassette server. - Pass the total you showed as the purchase's maximum: if the price went up since, nothing is charged.
- No wallet (opened outside Cassette without a browser wallet): tell the player to open it on Cassette or with a wallet.
- Self-refund works for items like for licences (
https://cassette.gg/agents/handle-refunds.md): the paywall shows whether it's available before the buy, and the player's refundable purchases.
Never trust the browser for anything valuable: credit coins and progress on your backend (https://cassette.gg/agents/verify-ownership.md).
E. Consumables on your backend
Read the purchase transaction's Sold events from your storefront and credit each sale once, keyed by storefront +
saleId (https://cassette.gg/agents/verify-ownership.md, "Credit a purchase once"). A sale inside its refund window can still be
self-refunded (the item is burned, the money goes back): credit what can't be taken back only after releaseAt, or
remove the credit when the sale shows refunded.
Limits on sponsored transactions
Cassette pays the network fee for storefront calls within published caps (GET https://cassette.gg/v1/wallet/rules).
- Batch. A sign link carries at most 8 calls and at most one storefront creation. Your relays go one call per request: 20 per minute and 500 per day per key.
- Tell the human when the work is large (more than 8 changes): how many signatures, or how many of the day's relays.
- On a refusal, don't retry in a loop (429
rate_limited,relay_budget): wait once for the window the message names and send the same call again (re-readlistingIdOffirst), or let the human send it from their own wallet, paying the network fee themselves.
Checks
curl -s $API/v1/storefronts/$SF/listings | jq -c '.[] | {listingId, kind, sku, price: .price.usd, sold, maxSupply, active}': each listing as indexed (a few seconds behind the chain).- The paywall's dry run:
cast call $SF "quote(uint256,uint64,address)(uint256,uint256)" 1 1 0x0000000000000000000000000000000000000000 --rpc-url $RPC→ the licence's total and fee (ListingInactive/NoSuchListing= not on sale). curl -s $API/v1/games/<slug> | jq '{offer, storefront: .storefront | {problem, license: .license.price}}': the store page shows the price from the storefront.- Test on a test platform first (never on chain 137): fund a test wallet with test USDC through
POST https://cassette.gg/v1/dev/faucet{"address":"0x…","usd":"10.00"}(the route exists on test chains only).
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.