cattown
Cat Town — Agent Overview
Cat Town is a Farcaster-native game world on Base. Players fish, collect, and earn KIBBLE; a share of town revenue is streamed weekly to KIBBLE stakers. This skill lets agents read Cat Town state and submit the transactions needed to participate.
The town's NPCs run each activity and are worth naming when talking to players:
- Wealth & Whiskers Bank — where KIBBLE staking happens. Theodore works the day shift, Cassie takes over in the evening.
- Paulie — runs the weekly fish raffle.
- Skipper — the weekday fishing NPC.
- Isabella — hosts the weekend fishing competition.
Current coverage:
- KIBBLE staking (RevenueShare) — stake, claim, claim-and-restake, unlock, relock, unstake, plus staking leaderboard and deposit history.
- World state (GameData) — live season, time of day, weather, weekend flag.
- Fishing drops — the public item-truth catalog filtered by world state (weather/season/time).
- Fishing competition (Isabella, Sat–Mon) — live prize-pool math, top-10 leaderboard, active/inactive response patterns.
- Fish raffle (Paulie, Fri 20:00 UTC draw) — free-ticket claim flow, tier-based prize pool, chance-to-win, leaderboard + last winners.
- Boutique — daily 3-item onchain rotation with KIBBLE→USD conversion via the Kibble Price Oracle.
- KIBBLE tokenomics — Jasper's math for % burned, % staked, and live staking APY.
Each surface has its own subdirectory under references/ for the deep reference. The weekly calendar below is the shared timing reference — many sections link back to it.
Links:
- Game: https://cat.town
- Bank (staking UI): https://cat.town/bank
- Docs: https://docs.cat.town
Weekly Calendar (all times UTC)
Cat Town runs on a fixed weekly cadence. Use these timings when setting user expectations ("your next fishing drop is Monday") or scheduling follow-ups.
| Day | Event | Time | Host | Affects staking rewards? |
|---|---|---|---|---|
| Monday | Fishing revenue deposit | by 12:00 (often earlier) | Theodore / Cassie | Yes |
| Mon–Fri | Fish raffle ticket sales open | — | Paulie | No |
| Mon–Fri | Weekday fishing | — | Skipper | No |
| Wednesday | Gacha revenue deposit | by 12:00 (often earlier) | Theodore / Cassie | Yes |
| Friday | Fish raffle draw | 20:00 | Paulie | No |
| Sat–Sun | Weekly fishing competition | Sat morning → Sun night | Isabella | Indirect* |
*During the weekend fishing competition (Sat–Sun), 10% of every fish identification feeds the KIBBLE stakers pool. Weekday fishing (Skipper) does not feed stakers. This is why weekend activity sizes the following Monday's fishing-revenue deposit. See references/world/calendar.md for the full revenue split.
Deposits are triggered by the Cat Town backend calling depositRevenue(amount, source) on RevenueShare, with source in "fishing" or "gacha". Watch the RevenueDeposited(string source, uint256 depositTimestamp, uint256 depositAmount, uint256 newAccRewardPerShare) event to know the exact moment a drop lands.
⚠️ CRITICAL: stake/unstake take INTEGER KIBBLE, not wei — read this first
This contract breaks the 99%-of-ERC-20 default. If you reflexively wei-encode the stake/unstake amount because "that's how tokens work," the tx reverts. This is the single failure mode you will hit on this contract. Stop here and internalize the table before touching any write path.
| Call | Amount unit | Example for 1 KIBBLE |
|---|---|---|
kibble.approve(revenueShare, …) |
wei (standard ERC-20) | 1000000000000000000 (= 1 × 10¹⁸) |
revenueShare.stake(uint256 amount) |
integer KIBBLE | 1 |
revenueShare.unstake(uint256 amount) |
integer KIBBLE | 1 |
Reads are also integer KIBBLE: getUserStaked, pendingRewards, getTotalStaked, getTotalActiveStaked.
Raw calldata — right vs. wrong
✅ stake(1) → 0xa694fc3a0000000000000000000000000000000000000000000000000000000000000001
❌ stake(1e18) → 0xa694fc3a0000000000000000000000000000000000000000000000000de0b6b3a7640000
The second form reverts with ERC20: transfer amount exceeds balance because the contract multiplies your argument by 10^18 internally, turning 1e18 into 1e36 wei. Verified via simulation against the deployed contract on Base.
Pre-submit validation (run this before every stake/unstake)
- Is the amount
< 1,000,000? → probably correct (integer KIBBLE). - Is the amount
≥ 10^15? → almost certainly wrong — you wei-encoded by reflex. - Sanity check:
stake(1)= 1 KIBBLE.stake(100)= 100 KIBBLE.stake(10000)= 10,000 KIBBLE. approveis the OPPOSITE — it is wei. StakingNKIBBLE requiresapprove(revenueShare, N * 10^18).
Signer = holder. The address that signs stake must be the same address that holds the KIBBLE and signed the approve.
KIBBLE Staking
Addresses (Base, chain id 8453)
- RevenueShare:
0x9e1Ced3b5130EBfff428eE0Ff471e4Df5383C0a1 - KIBBLE token (ERC-20, 18 decimals):
0x64cc19A52f4D631eF5BE07947CABA14aE00c52Eb
Base Sepolia addresses and the full ABI surface are in references/staking/contract.md. User-facing overview: https://docs.cat.town/economy/staking.
Core flows
Single pool, single reward token — KIBBLE in, KIBBLE out. No reward-token selection, no per-user lock duration, no multipliers. One global accRewardPerShare accumulator updated on each depositRevenue.
1. Stake (mixed units — re-read the CRITICAL section above if uncertain)
kibble.approve(revenueShare, amount_wei)—amount_wei = N * 10^18whereNis the KIBBLE count. Required once ifallowance(user, revenueShare) < amount_wei.revenueShare.stake(uint256 N)—Nis the integer KIBBLE count, NOT wei. If this reverts withERC20: transfer amount exceeds balance, you wei-encoded — pass the plain integer instead. EmitsStaked(user, amount).
2. Claim (after each fishing/gacha deposit)
revenueShare.claim()— transferspendingRewards(user)to the user. EmitsClaimed(user, amount).revenueShare.claimAndRestake()— claims and auto-adds to the user's stake in one tx. EmitsClaimedAndRestaked(user, restakedAmount, totalStakedNow).
3. Exit (unlock → wait → unstake)
revenueShare.unlock()— emitsUnlockInitiated(user, unlockEndTime). SetsisUnlocking[user] = true. Always tell the user two things when they unlock: (a) the wait is 14 days (LOCK_PERIOD= 1,209,600 seconds, snapshotted so later changes don't affect them), and (b) their pool share just dropped from whatever-it-was to 0% — they won't earn fishing or gacha deposits during the wait. Read the pre-unlock share first viagetPoolShareFraction(user) / 1e18 * 100.- Wait until
block.timestamp >= unlockEndTime(user). The 14-day value is safe to quote at the point of unlock. ReadLOCK_PERIOD()live only if you want defensive protection against future upgrades (the contract is UUPS-upgradeable). revenueShare.unstake(uint256 N)—Nis the integer KIBBLE count, same convention as stake. Reverts before the wait ends. EmitsUnstaked(user, amount).
revenueShare.relock()— at any time during the wait, cancels the unlock and puts the user back into the earning pool. EmitsRelocked(user, amount).
Checking remaining unlock time
When a user asks "how long until I can withdraw?", compute from unlockEndTime(user):
remaining_seconds = max(0, unlockEndTime(user) - current_unix_time)
isUnlocking(user) == false→ not unlocking, nothing to wait on.remaining_seconds > 0→ still waiting. Convert to days/hours for the reply.remaining_seconds == 0→unstake(N)is callable now.
No dedicated contract method for "time left" — just subtract. Use the latest block's timestamp if you want to avoid clock-skew with the user's device.
Mapping "unstake" / "withdraw" / "exit" to the right call
Users say "unstake" colloquially to mean the whole exit, not literally the onchain unstake() function. Before acting, read three values: isUnlocking(user), unlockEndTime(user), and the user's current pool share (getPoolShareFraction(user) / 1e18 * 100 as a percentage). Then route:
| State | What to call | What to tell the user |
|---|---|---|
isUnlocking(user) == false |
unlock() |
"Started your unlock. Wait is 14 days — ready at <unlockEndTime>. Your pool share just dropped from Y% to 0%; you won't earn revenue deposits during the wait. Call relock() any time to cancel and restore your share." |
isUnlocking == true and now < unlockEndTime |
(no tx) | "Already unlocking. ~X days Y hours left until you can withdraw. Your pool share is 0% until you either unstake() after the wait or relock() now." |
isUnlocking == true and now >= unlockEndTime |
unstake(N) |
"Withdrew N KIBBLE." (Or remaining balance if partial.) |
Same routing for "withdraw," "exit," "pull my KIBBLE out," "get my stake back." Never call the onchain unstake() as the first step — it reverts unless the user has already completed an unlock wait.
Why the share drop matters: while isUnlocking == true, the user's stake is removed from totalActiveStaked, so they do not earn any fishing or gacha revenue deposits that land during the 14-day wait. Surfacing the pre-unlock share (Y%) makes the opportunity cost explicit.
Unlock state machine — the gotcha to warn users about
[staking, earning] ──unlock()──▶ [unlocking, NOT earning] ──wait LOCK_PERIOD──▶ [unstake available]
▲ │ │
└────────── relock() ───────────┘ │
│ │
└────────────────────── unstake(amount) ◀──────────────────────────────────────┘
While isUnlocking[user] == true:
- The user's balance is in
totalStaked()but excluded fromtotalActiveStaked(). - Reward math divides by
totalActiveStaked, so the user does not accrue rewards during the unlock window. unstake()reverts untilunlockEndTime(user)has passed.
Recommend users claim() any pending rewards first, then unlock(), then unstake() once the wait is over. If they change their mind mid-wait, relock() is free and returns them to the earning pool instantly.
Reading a user's position
KIBBLE-denominated reads return whole KIBBLE (not wei). See the Amount units section above.
| Call | Returns / unit | Meaning |
|---|---|---|
getUserStaked(address) |
whole KIBBLE | Currently staked KIBBLE |
pendingRewards(address) |
whole KIBBLE | Claimable KIBBLE right now |
isUnlocking(address) |
bool |
True if user has called unlock() and not yet unstaked/relocked |
unlockStartTime(address) |
unix seconds | When unlock() was called |
unlockEndTime(address) |
unix seconds | When unstake() becomes callable |
getPoolShareFraction(address) |
fraction × 1e18 | User's share of the active pool |
getTotalActiveStaked() |
whole KIBBLE | Total KIBBLE earning rewards right now |
getTotalStaked() |
whole KIBBLE | Total KIBBLE in the contract (includes unlocking users) |
LOCK_PERIOD() |
seconds | Unlock wait duration |
accRewardPerShare() |
accumulator × 1e18 | Global reward accumulator |
Full function-by-function reference: references/staking/contract.md.
KIBBLE circulating supply — always subtract the burn address
When quoting "what % of KIBBLE is staked" (or any % of supply), compute against circulating supply, not totalSupply. KIBBLE has a deflationary burn mechanic: 2.5% of every fish identified is sent to 0x000000000000000000000000000000000000dEaD, and this compounds. The burned portion is already substantial — dividing by totalSupply materially undercounts the staked share (typically by ~3×).
totalSupply = 1,000,000,000 KIBBLE // fixed, read via totalSupply() on KIBBLE
burned = balanceOf(0x000000000000000000000000000000000000dEaD) on KIBBLE // read live
circulating = totalSupply − burned
percentStaked = getTotalStaked() / circulating × 100 // reads are whole-KIBBLE integers
Representative recent values (re-read live — the burn keeps growing):
totalSupply≈ 1,000,000,000 KIBBLEbalanceOf(0xdEaD)≈ 663M KIBBLE burned (~66%)- circulating ≈ 337M KIBBLE
getTotalStaked()≈ 81M KIBBLE → ~24% of circulating KIBBLE is staked
balanceOf(0x0) on KIBBLE is 0; the protocol burns to 0xdEaD only. If you must be exhaustive, check both, but 0xdEaD is where the number lives.
Staking leaderboard & user deposit history
Two public JSON endpoints on https://api.cat.town, no auth required. Use these whenever the user wants their rank, their share of the pool, or their weekly earnings history without paying RPC costs.
GET /v2/revenue/staking/leaderboard— ranked stakers with stake amount and pool-share %.GET /v2/revenue/deposits/{address}— one user's historicalfishing/gachadeposits, per-tx amounts, and the share that landed for that user.
Full shapes, field meanings, and example responses: references/staking/api.md.
World state
Cat Town's live world state (season, time of day, weather, weekend flag) lives on a single onchain contract — GameData at 0x298c0d412b95c8fc9a23FEA1E4d07A69CA3E7C34 on Base. Fully read-only from an agent's perspective.
The one call you usually want is getGameState() → (season, timeOfDay, isWeekend, worldEvent, weather). One RPC, every field:
- Season (
uint8):0=Spring,1=Summer,2=Autumn,3=Winter - TimeOfDay (
string):"Morning","Daytime","Evening","Nighttime" - Weather (
uint8):0=None,1=Sun,2=Rain,3=Wind,4=Storm,5=Snow,6=Heatwave - isWeekend (
bool): true on Sat/Sun UTC (the fishing-competition window) - worldEvent (
uint8): event code — detailed event decoding is out of scope for this skill revision
World state drives fishing and gacha drop tables — different fish appear in different weather/seasons. Fishing drop tables are documented in the Fishing drops section below; gacha pools are planned for a future revision.
Full function table, selectors, raw calldata, live sample response, and historical-lookup fns (getSeasonForDate, getWeatherForDate): references/world/contract.md.
For the fixed weekly cadence (fishing/gacha revenue deposits, Paulie's raffle, Isabella's weekend competition), see references/world/calendar.md.
Fishing drops — "what can I catch in this weather?"
When a user asks "what's catchable in the rain?", "what's exclusive to Winter evenings?", or "what drops in a Storm?", combine live world state (from GameData above) with Cat Town's public item catalog:
GET https://api.cat.town/v2/items/master?limit=1000 // public, no auth
Each item has optional dropConditions: { events?, seasons?, timesOfDay?, weathers? }. The frontend's fishing filter (ported verbatim from utils/helpers/fishingHelpers.tsx) is four steps:
- Keep only
isActive == true,source == "Fishing",itemType ∈ {"Fish", "Treasure"}. - Match the user-asked axis —
weathers/seasons/timesOfDay— includingaxis_valuein the item's condition array. - Drop items that require a seasonal event unless that event is currently active (Halloween items are invisible outside Halloween).
- Sort by rarity DESC, then name ASC.
This returns items exclusive to that axis value. getFishingItemsForWeather("Snow") → 3 snow-only drops, not the 400+ weather-agnostic items. That matches how the frontend surfaces "special drops this weather."
Enum mismatch to normalize: GameData contract returns timeOfDay as "Daytime" / "Nighttime"; the item API uses "Afternoon" / "Night". Weather and season strings match (after lowercasing).
Live example — weather=Storm: Misty Duck (Rare), Lovely Duck (Rare), King Snapper (Rare Fish), Elusive Marlin (Legendary Fish). Weather is the most rotational axis (minutes-to-hours), so weather-exclusive drops are the highest-value thing to surface to a user deciding when to fish.
Response pattern — lead with big-ticket specials, then offer the standard drops
When a user asks "what can I catch today / right now?", listing only the 3-4 axis-exclusive items feels incomplete. Answer in two tiers. Within each tier, lead with the big-ticket items so the reply opens with the most interesting catches.
Big-ticket sort (apply to every list you surface)
- Rarity DESC — Legendary → Epic → Rare → Uncommon → Common. This is the primary signal for fish (weight data isn't in the item API — see below).
sellValueDESC — useful tiebreaker within a rarity.sellValueis in cents USD (not KIBBLE). Real examples from the catalog: Legendary time-of-day rings (Solar, Dawnbreak, Moonlight, Twilight) sell at 25,000¢ = $250; Diamond and Frozen Tusk at 10,000¢ = $100; Gilded Sundial at 5,500¢.- Name ASC as final tiebreaker.
Two tiers
- Lead with the special drops — weather-exclusive and timeOfDay-exclusive items for the current state. Sort with the big-ticket order above — the Legendary goes first in the reply, not last.
- Count the "standard drops" also catchable today — items with NO
weathersand NOtimesOfDayconditions, whose season + event gates still pass. Baseline is ~26 per season. - Offer the deep dive. End with a prompt like: "There are X other standard drops you can also catch today — want me to list them?"
Concrete filter for standard drops (note: "standard" = not rotating on weather/time, as opposed to "special" — has nothing to do with the Common rarity tier):
standard_drops(current_season, current_event):
for item in catalog:
require item.isActive
require item.source == "Fishing"
require item.itemType in {"Fish", "Treasure"}
require item.dropConditions has no `weathers` array
require item.dropConditions has no `timesOfDay` array
if item.dropConditions.seasons is set:
require current_season in item.dropConditions.seasons
if item.dropConditions.events is set:
require current_event in item.dropConditions.events
Example reply for Storm / Spring / no active event — note Legendary leads:
Storm weather right now brings out 4 special drops, headed by Elusive Marlin (Legendary Fish). The rest: King Snapper (Rare Fish), Misty Duck (Rare Treasure), Lovely Duck (Rare Treasure).
You can also catch ~26 other standard Spring drops today, led by Alligator Gar (Legendary Fish), Diamond ($100 Epic Treasure), and Jade Figurine ($40 Epic). Want me to list the rest?
Fish weight data — not in the API, cross-reference the public docs
Per-species fish weight ranges are not returned by /v2/items/master. If a user asks about the heaviest fish or typical weights, cross-reference Cat Town's public docs (unauthenticated, human-readable):
- Fish weights + conditions: https://docs.cat.town/items/fishing/fish
- Treasure details: https://docs.cat.town/items/fishing/treasures
For quick programmatic answers, lean on rarity + sellValue. For "what's the biggest {species}", point the user at those docs pages.
Full recipe, complete weather→drops table, and live-sweep counts: references/fishing/drops.md. Player-facing context: https://docs.cat.town/fishing/start-fishing, https://docs.cat.town/fishing/hot-streaks, https://docs.cat.town/fishing/upgrades.
Fishing competition (weekly, Isabella hosts)
The FishingCompetition contract at 0x62a8F851AEB7d333e07445E59457eD150CEE2B7a (Base) runs a weekly competition Saturday 00:00 UTC → Monday 00:00 UTC. When a user asks about it, lead with live data, not with generic rules. The skeleton differs based on whether one is currently running.
Is one running?
- Onchain:
isCompetitionActive()→(bool active, bytes32 eventId) - API (public, no auth):
competition.isActiveinGET https://api.cat.town/v1/fishing/competition/leaderboard
Prize-pool math (mirror the frontend exactly)
The API's prizePool is total volume (all KIBBLE spent identifying fish during the competition). The frontend splits it three ways:
prizePool // total volume
leaderboardShare = prizePool * 0.10 // top-10 prize pool ("Prize Pool" in UI)
treasureShare = prizePool * 0.80 // treasures returned to fishers
stakersRevenue = prizePool * 0.10 // flows to KIBBLE stakers via RevenueShare
Top-10 distribution of leaderboardShare (from fishingLeaderboardShareForRank): 30%, 20%, 10%, 8%, 8%, 7%, 5%, 4%, 4%, 4%. Math.floor to whole KIBBLE.
If active — response pattern
Pull the API response once, then pick 3–5 of these to feature (keep it conversational, don't dump everything):
- Running time —
now - startTime("14 hours in, 34 hours to go") - Weather — from
GameData.getGameState()(drives which special fish appear) - Participants —
totalPlayers - Leaderboard prize pool —
prizePool * 0.10in KIBBLE + USD conversion via the oracle - Treasures returned to players —
prizePool * 0.80 - Stakers revenue generated —
prizePool * 0.10 - Top 10 — rank, basename (or short addr), fishName (+ shiny flag), weight in kg, expected payout
If NOT active — response pattern
- Say it clearly: "No fishing competition is running right now."
- Compute next start = next Saturday 00:00 UTC; express as "starts in X days Y hours."
- Offer a reminder: "Want me to ping you when it kicks off?"
- Ask the follow-up: "Do you want to hear about last week's competition?"
- If the user says yes, the same API response (even when inactive) carries the most recent completed competition — narrate winner, top-3 prizes, total volume, total participants.
Example reply — inactive with offer
There's no fishing competition running right now. The next one starts Saturday 00:00 UTC — about 2 days 14 hours away.
Want me to ping you when it kicks off? I can also tell you about last weekend's competition — 100 fishers, 3.06M KIBBLE total volume, and bitcoinbov.base.eth won with a 46.36 kg Elusive Marlin (~91,700 KIBBLE, ~$87).
Example reply — active, leading with live data
Fishing competition is live — 12 hours in, 36 hours to go. Weather's Storm 🌧️ (Elusive Marlin's biting).
- 27 fishers competing
- Leaderboard prize pool:
41,200 KIBBLE ($39) — 1st takes 30% (~12,360 KIBBLE)- Currently leading: alice.base.eth with a 42.8 kg Alligator Gar
- Also generating ~33k KIBBLE for KIBBLE stakers and ~264k returned to fishers as treasures this weekend
Want the full top 10?
Full ABI surface, per-rank payout worked example at current oracle rate, and the complete leaderboard response shape: references/fishing/competition.md. Player-facing overview: https://docs.cat.town/fishing/weekly-competition.
Boutique — daily 3-item shop
The boutique is a fully onchain daily shop. Every day at 00:00 UTC the Boutique contract surfaces 3 items deterministically selected from the current season's pool. No offchain API — all state is readable directly on Base.
Addresses
- Boutique:
0xf9843bF01ae7EF5203fc49C39E4868C7D0ca7a02 - Kibble Price Oracle (for USD conversion):
0xE97B7ab01837A4CbF8C332181A2048EEE4033FB7
Primary read — getTodaysRotationDetails()
Single call returns today's 3 items as ShopItemView[]. Each item carries price (in KIBBLE wei, divide by 10^18), stockRemaining, maxSupply, isPurchasableNow, and a traitNames/traitValues parallel pair that encodes Name, Rarity, Slot, Image. Parse those into a dict to render.
KIBBLE → USD conversion (the game UI doesn't do this — we should)
The in-game boutique shows KIBBLE prices only. To give users a USD readout, read the Kibble Price Oracle:
getKibbleUsdPrice()→uint256USD per 1 KIBBLE, scaled by10^18(not 1e8 — don't confuse withgetEthUsdPrice()which is10^8Chainlink style).- Formula:
usd_value = (price_wei * rawKibbleUsdPrice) / 10^36 - Live example: raw =
948,723,424,083,878→ $0.0009487 per KIBBLE → 10,000 KIBBLE ≈ $9.49.
Response pattern — "what's in the boutique today?"
- Parallel reads:
getTodaysRotationDetails()+getKibbleUsdPrice(). - For each of the 3 items: parse the trait arrays (Name/Rarity/Slot), compute KIBBLE and USD price, check stock.
- Sort big-ticket first — rarity DESC (Legendary → Common), then KIBBLE price DESC, then name ASC.
- Flag
stockRemaining == 0as "Sold Out"; otherwise format as"{stockRemaining} of {maxSupply} remaining"— stockRemaining first, maxSupply second. Sanity check: if your first number is larger than the second, you've swapped them — reread the struct fields.stockRemainingcan never exceedmaxSupply. - Open the reply with the current season; close with the matching
docs.cat.town/boutique/…-fashionlink for fuller context.
The collection name (e.g. "Spring Fashion") is on the item itself as the Collection trait — surface it at the top of the reply so the user knows which collection is currently rotating.
Example reply (real data from today's rotation) — note the "N of M remaining" phrasing:
Boutique today — Spring Fashion collection:
- White Longsleeve — Rare Body — 12,500 KIBBLE (~$11.86) — 1 of 1 remaining
- Royal Blue Varsity — Uncommon Body — 6,000 KIBBLE (~$5.69) — 2 of 2 remaining
- Classic Academic Blouse — Uncommon Body — 6,000 KIBBLE (~$5.69) — 1 of 2 remaining
Browse the other seasonal collections:
Include all four season links in every response — a user interested in the current collection will often want to peek at others.
Full ABI surface, trait schema (real keys: Item Name, Rarity, Item Type, Source, Slot, Sprite, imageUrl, Collection, etc.), preview future rotations, and the complete oracle math: references/boutique/contract.md.
Purchase flow is out of scope for this revision — this skill currently reads the boutique only.
Paulie's fish raffle (weekly, Fri 20:00 UTC draw)
FishRaffle at 0x5E183eBc7CA4dF353170C35b4D69Ea9f42317b28 (Base). Weekly ISO-week rounds: tickets sell Mon 00:00 UTC → Fri ~19:50 UTC, 5 winners drawn Fri 20:00 UTC via Chainlink VRF. Paid tickets burn 20 kg of caught fish each. Every wallet gets 1 free ticket per ISO week.
A second contract, FreeToPlayPool at 0x131E680dc7A146F00b282FBD7d6261c5B38c4Fa6, holds the prize pool balance and the tier table.
Claim the weekly free ticket
Preflight read, then write:
canClaimFreeTicket(address user) → bool // gate check
claimFreeTicket() // no args, msg.sender inferred
Always call canClaimFreeTicket(user) first. If false, surface the reason:
- Already claimed this week → "You've already claimed your free ticket this week. Next one resets Monday 00:00 UTC."
- Sales closed → "Sales closed at Fri 19:50 UTC. Winners draw at 20:00 UTC."
- Paused → "The raffle is paused."
Emits FreeTicketClaimed(user, roundId) on success. No token approval needed.
Current state — lead with live numbers
Read in parallel:
currentRoundId(),currentISOWeek(),paused(),salesClosed()FreeToPlayPool.poolBalance()+FreeToPlayPool.getTiers()GET https://api.cat.town/v1/tickets/leaderboard(public, no auth) — providestotalTicketsand the top buyers
Prize pool math (tier-based, not linear)
The prize pool is a fraction of poolBalance, set by the tier the round's totalTickets crosses into. 5 winners get an equal split.
tier = tiers.findLast(t => totalTickets >= t.minTickets) // highest threshold crossed
prize_pool = poolBalance * tier.bps / 10000 // in KIBBLE wei
per_winner = prize_pool / winnersPerDraw // equal split, NOT ranked
Live tier table:
| minTickets | bps | % of pool |
|---|---|---|
| 0 | 30 | 0.30% |
| 250 | 40 | 0.40% |
| 500 | 50 | 0.50% |
| 850 | 60 | 0.60% |
| 1,400 | 70 | 0.70% |
| 2,200 | 80 | 0.80% |
| 3,500 | 90 | 0.90% |
| 5,500 | 100 | 1.00% |
Live example (captured during writing): 2,855 tickets → 80 bps tier → 5,967,812 × 0.008 = ~47,742 KIBBLE prize pool → ~9,548 KIBBLE per winner (~$9). Last week's draw confirmed equal payouts of 9,363.86 KIBBLE to each of the 5 winners.
Chance to win
Use the proportional approximation in replies (accurate enough for small ticket counts):
chance ≈ min(1, winnersPerDraw * userTickets / totalTickets)
Exact form (C = binomial coefficient): 1 − C(totalTickets − userTickets, 5) / C(totalTickets, 5). Use the exact form only if the user asks for precision.
For a single free-ticket claimant in a 2,855-ticket round: 5 * 1 / 2855 ≈ 0.175%. For the current leader with 399 tickets: ~70%.
Leaderboard + last winners
GET https://api.cat.town/v1/tickets/leaderboard— current-round{ roundId, totalTickets, leaderboard[] }with per-buyertotalCount, basename, equipment.GET https://api.cat.town/v1/tickets/winners— most recent completed draw withroundId,timestamp, 5 winners (all with the sameprizeAmount, despite therankfield).
Response pattern — "tell me about the fish raffle"
canClaimFreeTicket(user)— so you can close with a relevant CTA.- Pull leaderboard + pool balance + current tier.
- Lead with live prize pool and per-winner split, then participant count, then top 3.
- Close with the user's status: "You've got N tickets → ~X% chance" or "Claim your free ticket? I can do it now."
Example reply (live state):
Paulie's raffle is open — round 31, draws Friday 20:00 UTC (about 2 days out).
- ~47,742 KIBBLE prize pool (~$45), split equally among 5 winners → ~9,548 KIBBLE each
- 2,855 tickets sold across 204 fishers; 645 more tickets unlock the 90-bps tier
- Top 3:
0xef05…(399 tickets, ~70% chance), bitcoinbov.base.eth (364, ~64%),0xdc6a…(310, ~54%)You haven't claimed your free ticket this week — want me to grab it?
Full ABI surface, write paths, tier math, live-worked chance calcs: references/fish-raffle/contract.md. API response shapes: references/fish-raffle/api.md. Player-facing overview: https://docs.cat.town/fishing/fish-raffle.
Paid tickets (buying with caught fish) are out of scope for this revision — free claim + reads only.
Gacha — async VRF pulls
GachaMachine at 0xAD0ee945B4Eba7FB8eB7540370672E97eB951F1a (Base) pays out seasonal items. Pulls are asynchronously fulfilled via VRF — one tx pays, a separate tx mints the NFT a few seconds later. Agents must account for the delay when answering "what did I get?".
Basics
- Daily cap: 100 pulls per wallet,
dailyUsageLimit()constant; resets at 00:00 UTC. Per-user remaining:getPlaysLeftForToday(user). - Cost: USD-denominated.
capsulePriceUSD()returns cents (currently 50 = $0.50 per pull). Convert to KIBBLE at pull time using the Kibble Price Oracle — same oracle as the boutique (0xE97B7ab01837A4CbF8C332181A2048EEE4033FB7, scale10^18). At current rates: ~527 KIBBLE per pull. - Drops are flat random. Every pull is independent, uniformly weighted against the current season's pool. No pity, no streaks, no reroll. Two pulls back-to-back are statistically identical.
- Seasonal pool is filterable via
/v2/items/master?limit=1000(public):source == "Gacha"+dropConditions.seasonsincludes the current season (fromGameData.getCurrentSeason()).
Write path — purchaseAndOpenCapsule() (payable)
Single pull per tx. Multi-pulls are N sequential txs — there's no onchain batch call.
Preconditions:
- kibble.allowance(user, gacha) >= kibble_cost (standard ERC-20, wei)
- msg.value = VRF fee (small ETH amount, per pull)
- getPlaysLeftForToday(user) > 0
- user holds enough ETH on Base for VRF fee + gas (see "ETH preflight" below)
Effect of the pay tx:
- pulls KIBBLE from user
- submits VRF randomness request
- does NOT mint the NFT — that happens in a separate tx on VRF callback
ETH preflight — check before pulling, suggest a swap if low
Gacha is the only Cat Town write in this skill that sends msg.value, so users who normally hold only KIBBLE can trip on it silently. Before building any pull tx, read the user's ETH balance on Base. If it's thin:
if user.ethBalance < ~ $0.50 USD:
# not enough headroom for VRF fee + gas across a few pulls
surface to user:
"You're low on ETH on Base ($X). Gacha pulls need a bit of ETH for
the VRF fee and gas. Want me to swap ~$1 of KIBBLE to ETH so you're
topped up?"
Rules for the swap suggestion:
- Prioritise KIBBLE as the source token. Most Cat Town users hold it already, and swapping a sliver back to ETH is the least-disruptive path. Fall back to other tokens only if KIBBLE balance is also insufficient.
- Default target: ~$1 of ETH. Enough for ~10+ pulls with comfortable gas headroom. Scale up for bigger batches (roughly
max($1, $0.08 × N)for N pulls). - Offer, don't auto-execute. Present it as a confirmation before running the swap, unless the user explicitly said "just do it."
- Bankr's built-in swap (via the
trailsorsymbiosisskill) handles the actual swap — this skill just triggers the suggestion at the right moment.
If the user declines the swap but still has some ETH, proceed with whatever pulls that ETH covers and tell them how many before the wallet runs dry.
Reading the result — the token-id ordering trick
Because the pay tx and the mint tx are decoupled, the frontend correlates them by capsule token id (mirrored here). Process for one or many pulls:
1. Before pulling:
latestId = max( item.id for item in GET /v2/items/capsule/<user> )
(If the user has never pulled, the endpoint returns a 500 — treat it as empty, latestId = 0.)
2. Submit N pay txs.
3. After confirmations, poll GET /v2/items/capsule/<user> every 1–2 s:
newItems = [ item for item in response if item.id > latestId ]
if len(newItems) >= N: return newItems (these are your results)
4. Time out at ~60 s. If not all N have landed, surface a "still pending" reply.
If the user spins 10 times, you must wait for 10 items with id > latestId. Partial results are fine to preview, but be explicit about how many are still pending. Don't assume pull-1's result has a smaller id than pull-2's — VRF callbacks can interleave.
Response patterns
Can Bankr poll for results? If yes, use the loop above and report when all N have landed. If not, submit the pay tx(s), return immediately with "Spin submitted — ask me again in ~30 seconds to see what dropped" and let the user re-prompt. When they come back, pull /v2/items/capsule/<user> and show items with id > latestId (where latestId was cached in the original turn).
Always quote the item's value + offer a sell
When reporting a gacha result, look up the item's sellValue (in US cents) from /v2/items/master, convert to KIBBLE via the oracle, and surface both numbers. Then offer to sell — this skill has a vendor flow (see Selling items below).
Format: "<Rarity> <Name>" (<Type>, <Collection>) worth ~<X> KIBBLE (~$<Y>). Example for Fern: "Common Fern (Collectible, Plant Minis) worth ~105 KIBBLE (~$0.10)".
Example replies
Polling path (Bankr can wait):
Spinning once… paid ~527 KIBBLE. Waiting on VRF…
🎉 You pulled a Common Fern (Collectible, Plant Minis collection) worth
105 KIBBLE ($0.10). You have 99 pulls left today.Want me to sell it for you? After the 5% vendor fee, you'd get ~100 KIBBLE.
Non-polling path (no async support):
Submitted 5 pulls (~2,635 KIBBLE total). VRF needs a few seconds to mint each one. Ask me "what did I get?" in ~30 seconds and I'll check — I can also sell the results right away if you want.
Reads cheat-sheet
| Call | Use case |
|---|---|
dailyUsageLimit() |
Global 100/day cap |
getPlaysLeftForToday(user) |
Remaining pulls for this wallet today |
capsulePriceUSD() |
Cost per pull in US cents |
getAllItemConfigs() / getItemConfig(index) |
Onchain pool definitions |
GET /v2/items/capsule/<user> |
Result polling target |
GET /v2/items/master?limit=1000 |
Full catalog; filter source=Gacha |
Full contract signatures, VRF event names, oracle math, and the capsule API quirks (500 for cold wallets, etc.): references/gacha/contract.md, references/gacha/api.md. Player-facing overview + pool archive: https://docs.cat.town/shops/gacha, https://docs.cat.town/items/gacha/archive.
Selling items (vendor, V2 minter only)
Players sell Treasures and Collectibles (including gacha pulls) to the SellItems contract at 0x49936db5Dcbc906D682CFa2dcfAb0788e3ee5808 for KIBBLE, minus a 5% merchant fee.
This skill revision supports only items minted by the V2 minter (0x7b65ec82cB4600Bc1dCc5124a15594976f19eA14). Legacy V1-minted items must be filtered out in the preflight.
Value math
Each sellable item has a sellValue in the public item catalog — US cents, not KIBBLE, not wei:
GET https://api.cat.town/v2/items/master?limit=1000
→ items[].sellValue (cents, e.g. 10 = $0.10)
Convert to KIBBLE for display via the Kibble Price Oracle:
usd = sellValue / 100
kibble_value = usd / (rawKibbleUsdPrice / 10^18)
payout_after_tax = kibble_value * 0.95 // 5% vendor fee
A freshly minted NFT (e.g. a gacha pull) also carries a Sell Value (KIBBLE) trait with the pre-computed KIBBLE amount. Prefer the trait when available; fall back to the catalog formula.
Write flow
Single function, batched up to 25 items per call:
SellItems.sellMultipleNFTsToContract(
address[] nftContracts, // V2 minter address repeated, one per item
uint256[] tokenIds, // token ids to sell
uint256[] amounts // 1 per item (ERC-1155)
)
Preflight:
- Approval — check
V2Minter.isApprovedForAll(user, sellContract). If false, submitsetApprovalForAll(sellContract, true)first. One-time per wallet. - V2 filter — only include items whose source nftContract is the V2 minter. Skip V1, tell the user how many were skipped.
- Ownership —
V2Minter.balanceOf(user, tokenId) >= 1for each item. - Vendor liquidity —
KIBBLE.balanceOf(sellContract)must exceed total payout; otherwise revertsKibbleTransferFailed("vendor is out of KIBBLE").
Tax rate is read from taxRateInBps() (currently 500 = 5%, rounded from chain on the frontend).
Inventory API — "what can I sell?"
GET https://api.cat.town/v2/inventory/<address>/paginated?hasSellValue=true&sortBy=kibble&sortOrder=desc
Public, no auth. hasSellValue=true filters out unsellable types automatically. Sort by kibble to surface the highest-value items first — mirrors how the frontend's vendor modal opens.
Response pattern — "sell my items"
- Pull inventory via the API above.
- Filter to V2-minted items only.
- Sum expected payout (
sellValuesummed, converted to KIBBLE, × 0.95). - Confirm with the user: "I'll sell N items for
X KIBBLE ($Y) after the 5% fee. Go ahead?" - Run the approval if needed, then
sellMultipleNFTsToContract(...). - After confirmation, refetch inventory + KIBBLE balance and report the actual payout.
Example reply after a gacha pull:
You pulled a Common Fern (~105 KIBBLE, $0.10). Want me to sell it right away? That'd net ~100 KIBBLE after the 5% fee.
Or, for a batch:
You've got 12 V2-minter items worth selling, totaling ~3,420 KIBBLE after the 5% fee. (Skipping 2 legacy items.) Want me to sell all 12, or cherry-pick?
Full ABI surface, approval detail, inventory-API query params, revert catalogue, and the batch recipe: references/sell-items/contract.md. Player-facing overview: https://docs.cat.town/shops/sell-items.
KIBBLE tokenomics (Jasper's answers)
When a user asks about KIBBLE — "how much is staked?", "how much burned?", "what's the APY?" — mirror the numbers the NPC Jasper quotes at the Wealth & Whiskers Bank. Three headline stats, each from live reads:
% Burned (of TOTAL supply)
burnedPercent = balanceOf(0xdEaD on KIBBLE) / 1,000,000,000 × 100
Denominator is total supply (1B), not circulating — that's how Jasper phrases it. Live at time of writing: ~66.3% of supply already burned.
% Staked (of CIRCULATING supply)
circulating = totalSupply − balanceOf(0xdEaD)
stakedPercent = RevenueShare.getTotalStaked() / circulating × 100
Denominator is circulating (total minus burned), so users get a realistic number after the deflationary burn. Live at time of writing: ~24.0% of circulating KIBBLE is staked.
Staking APY at Wealth & Whiskers
Derived dynamically from baronbot (0x8Ff7AcCCf73c515c1f62Fc7b64A63F17Ce99659e, rank-1 continuous staker) because the return per KIBBLE is the same for every active staker. Formula:
1. GET /v2/revenue/deposits/<baronbot> — keep last 30 days of deposits
2. monthly_revenue = period_revenue * (30 / days_since_first_deposit)
3. monthly_rate = monthly_revenue / baronbot.stakedAmount
4. apy = min(((1 + min(monthly_rate, 0.50))^12 − 1) * 100, 1000)
Live at time of writing: ~30% APY — not a fixed rate; drifts with weekly fishing + gacha revenue.
Example reply
KIBBLE tokenomics (live): ~66% of supply has been burned, ~24% of circulating is staked in Wealth & Whiskers, and staking currently pays ~30% APY. Want me to walk you through staking? The lock period is 14 days.
Full formulas, APY caps, and the live worked example: references/kibble/tokenomics.md. Player-facing KIBBLE economy overview: https://docs.cat.town/economy/tokens/kibble, https://docs.cat.town/get-started/kibble-economy.
Executing transactions via Bankr
For any write call (approve, stake, claim, claimAndRestake, unlock, relock, unstake):
Natural-language Bankr agent prompt:
bankr agent prompt "Stake 1000 KIBBLE in Cat Town"
Or encode calldata and submit directly:
bankr wallet submit --to 0x9e1Ced3b5130EBfff428eE0Ff471e4Df5383C0a1 --data <encoded-calldata> --chain base
Remember: submit the ERC-20 approve on the KIBBLE token (0x64cc19A52f4D631eF5BE07947CABA14aE00c52Eb, target = RevenueShare) before stake if the current allowance is insufficient.
Pitfalls
- Forgetting the approval.
stakereverts cleanly but wastes a user's tx. Readallowance(user, revenueShare)first; only approve if low. - Unstaking while unlocking. Reverts. Check
isUnlocking(user)andunlockEndTime(user)before constructing anunstaketx. - Assuming continuous rewards.
pendingRewardsis a step function — it only goes up when the backend callsdepositRevenue. Between deposits, polling will show no change, and that is correct. Use the calendar above to set expectations. - Stale
LOCK_PERIODassumptions. Currently 14 days (1,209,600 seconds); safe to quote at the point of unlock becauseunlockEndTimeis snapshotted per-user. ReadLOCK_PERIOD()live only if you want defensive protection against UUPS upgrades. - Using the legacy contract. An older staking contract (
0xc3398Ae89bAE27620Ad4A9216165c80EE654eE96) exists but is deprecated. Do not send new stakes there.
Troubleshooting
ERC20: transfer amount exceeds balance on stake
99% certainty: you wei-encoded the stake argument. RevenueShare takes amount in whole KIBBLE and multiplies by 10^18 internally. If you pass N × 10^18 thinking it's wei, the contract attempts to pull N × 10^36 tokens from your balance, which trivially exceeds any balance.
Fix: pass the whole-KIBBLE integer. To stake 100 KIBBLE, call stake(100), not stake(100000000000000000000).
The KIBBLE approve() call is the opposite — it's a standard ERC-20 call and does take wei. So the correct 100-KIBBLE flow is:
kibble.approve(revenueShare, 100_000000000000000000) // 100 × 10^18 wei
revenueShare.stake(100) // whole KIBBLE
Confirmed by onchain simulation against 0x9e1Ced3b5130EBfff428eE0Ff471e4Df5383C0a1:
| Call | Expected behaviour |
|---|---|
stake(1) with ≥1 KIBBLE allowance |
succeeds (stakes 1 KIBBLE) |
stake(100) with =100 KIBBLE allowance |
succeeds, hits cap exactly |
stake(101) with 100 KIBBLE allowance |
reverts transfer amount exceeds allowance |
stake(1e18) with 100 KIBBLE allowance |
reverts transfer amount exceeds balance ← the mistake |
ERC20: transfer amount exceeds allowance on stake
stake(N) requires allowance(signer, revenueShare) ≥ N × 10^18 on the KIBBLE token. Call approve(revenueShare, N × 10^18) from the same signer first.
unstake reverts with no obvious reason
Check isUnlocking(signer). If true, unstake reverts until block.timestamp >= unlockEndTime(signer). Either wait out the window or call relock() to cancel the unlock and return to the earning pool.
Diagnostic no-arg write tests
To verify the signer + contract are wired up without any amount-encoding risk:
unlock()— no args. Succeeds even with 0 staked (setsisUnlocking = true). Follow withrelock()immediately to avoid side effects.claim()— no args. No-ops cleanly whenpendingRewards(signer) == 0.