cattown

Installation
SKILL.md

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:


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.
  • approve is the OPPOSITE — it is wei. Staking N KIBBLE requires approve(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)

  1. kibble.approve(revenueShare, amount_wei)amount_wei = N * 10^18 where N is the KIBBLE count. Required once if allowance(user, revenueShare) < amount_wei.
  2. revenueShare.stake(uint256 N)N is the integer KIBBLE count, NOT wei. If this reverts with ERC20: transfer amount exceeds balance, you wei-encoded — pass the plain integer instead. Emits Staked(user, amount).

2. Claim (after each fishing/gacha deposit)

  • revenueShare.claim() — transfers pendingRewards(user) to the user. Emits Claimed(user, amount).
  • revenueShare.claimAndRestake() — claims and auto-adds to the user's stake in one tx. Emits ClaimedAndRestaked(user, restakedAmount, totalStakedNow).

3. Exit (unlock → wait → unstake)

  1. revenueShare.unlock() — emits UnlockInitiated(user, unlockEndTime). Sets isUnlocking[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 via getPoolShareFraction(user) / 1e18 * 100.
  2. Wait until block.timestamp >= unlockEndTime(user). The 14-day value is safe to quote at the point of unlock. Read LOCK_PERIOD() live only if you want defensive protection against future upgrades (the contract is UUPS-upgradeable).
  3. revenueShare.unstake(uint256 N)N is the integer KIBBLE count, same convention as stake. Reverts before the wait ends. Emits Unstaked(user, amount).
  • revenueShare.relock() — at any time during the wait, cancels the unlock and puts the user back into the earning pool. Emits Relocked(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 == 0unstake(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 from totalActiveStaked().
  • Reward math divides by totalActiveStaked, so the user does not accrue rewards during the unlock window.
  • unstake() reverts until unlockEndTime(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 KIBBLE
  • balanceOf(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 historical fishing / gacha deposits, 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:

  1. Keep only isActive == true, source == "Fishing", itemType ∈ {"Fish", "Treasure"}.
  2. Match the user-asked axis — weathers / seasons / timesOfDay — including axis_value in the item's condition array.
  3. Drop items that require a seasonal event unless that event is currently active (Halloween items are invisible outside Halloween).
  4. 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)

  1. Rarity DESC — Legendary → Epic → Rare → Uncommon → Common. This is the primary signal for fish (weight data isn't in the item API — see below).
  2. sellValue DESC — useful tiebreaker within a rarity. sellValue is 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¢.
  3. Name ASC as final tiebreaker.

Two tiers

  1. 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.
  2. Count the "standard drops" also catchable today — items with NO weathers and NO timesOfDay conditions, whose season + event gates still pass. Baseline is ~26 per season.
  3. 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):

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.isActive in GET 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 timenow - startTime ("14 hours in, 34 hours to go")
  • Weather — from GameData.getGameState() (drives which special fish appear)
  • ParticipantstotalPlayers
  • Leaderboard prize poolprizePool * 0.10 in KIBBLE + USD conversion via the oracle
  • Treasures returned to playersprizePool * 0.80
  • Stakers revenue generatedprizePool * 0.10
  • Top 10 — rank, basename (or short addr), fishName (+ shiny flag), weight in kg, expected payout

If NOT active — response pattern

  1. Say it clearly: "No fishing competition is running right now."
  2. Compute next start = next Saturday 00:00 UTC; express as "starts in X days Y hours."
  3. Offer a reminder: "Want me to ping you when it kicks off?"
  4. Ask the follow-up: "Do you want to hear about last week's competition?"
  5. 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()uint256 USD per 1 KIBBLE, scaled by 10^18 (not 1e8 — don't confuse with getEthUsdPrice() which is 10^8 Chainlink 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?"

  1. Parallel reads: getTodaysRotationDetails() + getKibbleUsdPrice().
  2. For each of the 3 items: parse the trait arrays (Name/Rarity/Slot), compute KIBBLE and USD price, check stock.
  3. Sort big-ticket first — rarity DESC (Legendary → Common), then KIBBLE price DESC, then name ASC.
  4. Flag stockRemaining == 0 as "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. stockRemaining can never exceed maxSupply.
  5. Open the reply with the current season; close with the matching docs.cat.town/boutique/…-fashion link 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:

  1. White Longsleeve — Rare Body — 12,500 KIBBLE (~$11.86) — 1 of 1 remaining
  2. Royal Blue Varsity — Uncommon Body — 6,000 KIBBLE (~$5.69) — 2 of 2 remaining
  3. 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) — provides totalTickets and 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-buyer totalCount, basename, equipment.
  • GET https://api.cat.town/v1/tickets/winners — most recent completed draw with roundId, timestamp, 5 winners (all with the same prizeAmount, despite the rank field).

Response pattern — "tell me about the fish raffle"

  1. canClaimFreeTicket(user) — so you can close with a relevant CTA.
  2. Pull leaderboard + pool balance + current tier.
  3. Lead with live prize pool and per-winner split, then participant count, then top 3.
  4. 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, scale 10^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.seasons includes the current season (from GameData.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 trails or symbiosis skill) 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:

  1. Approval — check V2Minter.isApprovedForAll(user, sellContract). If false, submit setApprovalForAll(sellContract, true) first. One-time per wallet.
  2. V2 filter — only include items whose source nftContract is the V2 minter. Skip V1, tell the user how many were skipped.
  3. OwnershipV2Minter.balanceOf(user, tokenId) >= 1 for each item.
  4. Vendor liquidityKIBBLE.balanceOf(sellContract) must exceed total payout; otherwise reverts KibbleTransferFailed ("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"

  1. Pull inventory via the API above.
  2. Filter to V2-minted items only.
  3. Sum expected payout (sellValue summed, converted to KIBBLE, × 0.95).
  4. Confirm with the user: "I'll sell N items for X KIBBLE ($Y) after the 5% fee. Go ahead?"
  5. Run the approval if needed, then sellMultipleNFTsToContract(...).
  6. 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. stake reverts cleanly but wastes a user's tx. Read allowance(user, revenueShare) first; only approve if low.
  • Unstaking while unlocking. Reverts. Check isUnlocking(user) and unlockEndTime(user) before constructing an unstake tx.
  • Assuming continuous rewards. pendingRewards is a step function — it only goes up when the backend calls depositRevenue. Between deposits, polling will show no change, and that is correct. Use the calendar above to set expectations.
  • Stale LOCK_PERIOD assumptions. Currently 14 days (1,209,600 seconds); safe to quote at the point of unlock because unlockEndTime is snapshotted per-user. Read LOCK_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 (sets isUnlocking = true). Follow with relock() immediately to avoid side effects.
  • claim() — no args. No-ops cleanly when pendingRewards(signer) == 0.
Weekly Installs
2
Repository
bankrbot/skills
GitHub Stars
1.1K
First Seen
Today