Cassette

handle-refunds

Online page: https://cassette.gg/agents/handle-refunds.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 and their storefront contract holds the sales. Cassette never refunds a sale, holds no refund key, can't pause or change a storefront and doesn't decide disputes. Removing a game from Cassette only removes it from discovery. Never tell a buyer that Cassette will refund them or decide their case.

Who can do what:

ActionWhoHow
Self-refund inside the refund windowthe buyer (the wallet that paid)the game's paywall, or the Cassette library
Release a sale's payment after its windowanyonerelease(uint256[]), relayed by your key (section 3)
Refund any sale, any timethe storefront owner (the human)refund(uint256) in a sign link (section 4)
Allow or deny one wallet's self-refundthe ownersetRefundOverride in a sign link (section 5)
Change the refund window, cap or abuse-registry choicethe ownersetRefundPolicy in a sign link, with new documents (section 6)

Your key is an operator: it can list items and release payments, never refund or change the refund rules. For every owner action you prepare a sign link (protocol.md §6): send it to the human, say what it does, and check the effect on chain afterwards.

0. Requirements

  • https://cassette.gg/agents/protocol.md: the shell variables ($API, $CFG, $RPC, $CHAIN, $FACTORY, $ROUTER, $USDC, $KEY, $ME), and a sign-in token $TOKEN (§4) for the sales list.
  • SF = storefront.address in cassette.json; cast call $FACTORY "isStorefront(address)(bool)" $SF --rpc-url $RPC is true (else there's no live storefront yet: https://cassette.gg/agents/publish-game.md, steps 5–7).
  • sf_relay <storefront> <calldata> from https://cassette.gg/agents/sell-items.md, section C (your key's gas-free call).
  • The storefront as indexed: curl -s $API/v1/storefronts/$SF | jq '{owner, payout, operators, refundPolicy, referral, sellerRecord}'.

Functions (on $SF):

FunctionWho
release(uint256[] saleIds)anyone
refund(uint256 saleId)owner
setRefundOverride(address buyer, uint8 value): 0 none (the policy), 1 always allow, 2 never allowowner
setRefundPolicy((uint32 window, uint16 maxSelfRefunds30d, address registry))owner
releasable(uint256) returns (bool), owner() returns (address), refundOverride(address) returns (uint8), refundPolicy() returns ((uint32,uint16,address))view
sale(uint256) returns ((uint256 listingId, address buyer, address recipient, uint64 qty, uint64 releaseAt, uint128 amount, uint128 fee, address feeTo, uint8 status)): status 1 escrowed, 2 released, 3 refundedview

1. What buyers get (the template: 6 hours, 3 per 30 days, registry off)

  • Every paid sale is held in the storefront (escrow) until the refund window the storefront had at the time of the sale ends. Inside it, the buyer can refund themselves: the full price comes back from escrow and the licence or items are burned. A window of 0 pays the developer at once and allows no self-refund.
  • The paywall says it under the Buy button ("Changed your mind? Refund yourself within 6 hours of buying.") and can show a "Refund until …" button while the player's purchase is refundable. The Cassette library shows the same option and the receipt.
  • Self-refund is blocked, and the paywall says why before the buyer pays, when: the seller denied this wallet (developer-denied), the wallet reached the cap of self-refunds in 30 days across all storefronts from Cassette's factory (ledger-cap), the storefront uses the abuse registry and the wallet is restricted (registry-restricted), the window closed (window-closed), the tokens were moved or used (tokens-gone), it was a gift (gift: the payer isn't the recipient), or the listing is a limited edition (limited-supply). Blocked buyers can still buy, play, and ask the seller for a refund.
  • Check one sale: curl -s $API/v1/sales/$SF/<saleId>/self-refund → {ok, reason, refundableUntil, restriction}.

2. See the sales

curl -s "$API/v1/storefronts/$SF/sales?state=escrowed&limit=50" -H "authorization: Bearer $TOKEN" \
  | jq -c '.items[] | {saleId, title, buyer, usd: .total.usd, state, refundableUntil}'
# {"saleId":"3","title":"Blob Run","buyer":"0x3C44…","usd":"4.99","state":"escrowed","refundableUntil":"2026-10-30T22:15:00.000Z"}
  • Owner or operator keys only (403 forbidden otherwise). state: escrowed, released or refunded (omit it for all); each item's state is escrowed, released, self-refunded or refunded. Pages: limit ≤ 200, then cursor=<nextCursor>.
  • One sale, no key: GET $API/v1/sales/$SF/<saleId>. A receipt in the seller's name: GET $API/v1/receipts/$SF/<saleId> or GET $API/v1/receipts/tx/<txHash>.
  • The API is an index, seconds behind the chain. The chain decides: cast call $SF "releasable(uint256)(bool)" <saleId>.

3. Release payments (after the window)

Anyone may release, so your key does it, gas-free and without the human. Pay out every releasable sale, up to 40 per call, e.g. once a day:

IDS=$(curl -s "$API/v1/storefronts/$SF/sales?state=escrowed&limit=200" -H "authorization: Bearer $TOKEN" | jq -r '.items[].saleId' \
  | while read -r id; do [ "$(cast call $SF "releasable(uint256)(bool)" $id --rpc-url $RPC)" = true ] && echo $id; done \
  | head -40 | paste -sd, -)
[ -n "$IDS" ] && sf_relay $SF "$(cast calldata "release(uint256[])" "[$IDS]")"
# {"txHash":"0x…","status":"mined"}       (no output: nothing to release yet)

The money goes to the storefront's payout address (the owner's wallet unless they set another). One id that isn't releasable makes the whole call revert (NotReleasable): check each with releasable as above. Cassette's referral fee (0% for now) is taken at release, only on sales Cassette referred.

4. Refund a sale (the human signs)

Only the owner refunds. Find the saleId (section 2, or the buyer's receipt), confirm with the human that they want to refund it, then prepare the link:

SALE=$(curl -s $API/v1/sales/$SF/3)                      # state, total.units, buyer
OWNER=$(cast call $SF "owner()(address)" --rpc-url $RPC)
jq -n --argjson chain $CHAIN --arg me $ME --arg owner $OWNER --arg sf $SF --arg usdc $USDC --argjson sale "$SALE" \
  --arg data "$(cast calldata "refund(uint256)" 3)" '
  {v:1, chainId:$chain, purpose:"refund", agent:$me, owner:$owner, calls:[{to:$sf, data:$data}],
   summary:["Refund sale #\($sale.saleId): \($sale.total.usd) USDC to \($sale.buyer)"]}
  + if $sale.state == "released" then {approvals:[{token:$usdc, spender:$sf, amount:$sale.total.units}]} else {} end' > req.json
echo "https://cassette.gg/studio/sign#$(jq -cj . req.json | base64 -w0 | tr '+/' '-_' | tr -d '=')"

Send the human the link ("Open it, sign in with the wallet that owns your storefront, check the refund and confirm").

  • state escrowed: the money is still in the storefront; nothing else is needed.
  • state released: the sale was paid out, so the refund comes from the owner's own wallet: the link also approves exactly that amount of USDC to the storefront, and the wallet must hold it. A referral fee already paid isn't returned.
  • refunded or self-refunded: nothing to do.
  • The refund burns what the buyer still holds of that sale. After they sign: curl -s $API/v1/sales/$SF/3 | jq .state is "refunded" (chain: sale(3) status 3).

5. One buyer's self-refund (the human signs)

DATA=$(cast calldata "setRefundOverride(address,uint8)" 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC 1)   # 1 allow, 2 deny, 0 = the policy

Put it in a link like section 4 with purpose:"set-refund-override" (no approvals). Allow: this wallet can always self-refund inside the window (skips the cap and the abuse registry). Deny: never. 0: back to the storefront's policy. The developer's decision always beats the abuse registry. Each sale keeps the decision that applied when it was sold, except that an allow set later still helps the buyer. Check: cast call $SF "refundOverride(address)(uint8)" <buyer>.

6. Change the refund policy (the human signs and adopts)

The refund policy document must say what the storefront does. Ask the human for the new values (window 0–336 hours, i.e. up to 14 days; cap 0–65535, 0 = none; abuse registry on or off), then:

  1. Update seller.json, fill and hash the documents again and have the human adopt the new text (https://cassette.gg/agents/set-up-seller.md, steps 2–6 and 8): a new version and a new seller record hash.
  2. Deploy the new documents and record (https://cassette.gg/agents/host-game.md), then send the link at once: until it's signed, the served documents and the storefront disagree, and paywalls checking them may pause sales.
  3. One link with purpose:"set-refund-policy" (at most 8 calls): the policy, the new seller record, and the new terms hash on each listing:

    REGISTRY=0x0000000000000000000000000000000000000000                 # on: $(jq -r .deployment.abuseRegistry <<<"$CFG")
    POLICY=$(cast calldata "setRefundPolicy((uint32,uint16,address))" "($((24*3600)),3,$REGISTRY)")
    RECORD=$(cast calldata "setSellerRecord(string,bytes32)" $RECORD_URI $RECORD_HASH)
    # + updateListing(id, price, true, "", termsURI, termsHash) per listing (sell-items.md, section B)
  4. After they sign: cast call $SF "refundPolicy()((uint32,uint16,address))" --rpc-url $RPC → (86400 [8.64e4], 3, 0x0000000000000000000000000000000000000000).

A new policy applies to future sales only; earlier sales keep the terms they were sold under. BadRefundPolicy: a window over 14 days, or a registry other than Cassette's (deployment.abuseRegistry) or none.

7. Cassette's abuse registry (opt-in)

  • It's a list Cassette keeps of wallets that abused self-refund. It can only switch off self-refund, and only at storefronts whose owner opted in. A restricted wallet can still buy, play, keep what it bought and get refunds from the developer. It never affects the developer's own refunds.
  • A rule only opens a case; a person reviews every case before a restriction. Restrictions last 30 days (90 for a repeat) and never more than 90 days. Criteria: https://cassette.gg/refund-abuse (JSON: GET https://cassette.gg/v1/abuse/criteria).
  • Check a wallet: GET https://cassette.gg/v1/abuse/status/<address> → {restriction, selfRefunds30d}.
  • Appeals belong to the buyer and go to Cassette, not the developer: the buyer replies to the notice they were shown (or POST https://cassette.gg/v1/abuse/appeals signed in), and someone not involved in the decision answers within 14 days. The developer can always decide for themselves instead: allow that wallet (section 5).
  • The self-refund cap is separate: plain arithmetic in the storefront, no Cassette decision.

8. Complaints Cassette passes on

Buyers contact the seller first (the contact in seller.json, shown on the paywall and the receipt). When a buyer writes to Cassette instead, Cassette passes the message on to that contact and may follow up; it doesn't decide the case and doesn't refund. The developer answers purchase questions, cancellations and refund requests within 14 days.

  1. Find the sale: the buyer's wallet in section 2, or GET https://cassette.gg/v1/receipts/tx/<txHash>.
  2. Show the human the facts (what was bought, when, amount, state) and let them decide. Don't promise anything for them.
  3. Business sellers: a buyer who didn't give the immediate-delivery consent keeps a 14-day right to cancel and must be refunded within 14 days if they cancel. The consent is the lowest bit of the sale's ref: REF=$(curl -s $API/v1/sales/$SF/3 | jq -r .ref); echo $(( 0x${REF:2:2} & 1 )) (1 = consented).
  4. Refund: section 4. Allow a self-refund instead: section 5.

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. Owner refunds are one link each: for many refunds, tell the human how many links to expect before you start. Your relays go one call per request (20 per minute, 500 per day per key); one release pays out up to 40 sales.
  • 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, or let the human send it from their own wallet, paying the network fee themselves.

Errors

Code or revertMeaningFix
403 forbidden (sales list)this key isn't the storefront's owner or an operatorsign in with the operator key; or the human adds it (setOperator, sign link)
relay_reverted … NotReleasablea sale still in its window, or already paid out or refundedre-check releasable per id
NotOwner (on the sign page)the wallet signing isn't the storefront's ownerthe human signs with the wallet that owns it (owner())
AlreadyRefundedrefunded alreadynothing to do
BadRefundPolicywindow over 336 hours, or a registry other than Cassette'ssection 6
not_found (/v1/sales/…)not indexed yet, or no such salewait a few seconds; the chain: sale(saleId)
NotStorefrontthat address isn't a storefront from Cassette's factorycheck storefront.address in cassette.json

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.