garden.gno
97.69 Kb · 2316 lines
1// Package garden is Gnotchi's pet realm, v5: the free, allowlisted, drand-rolled, hold-many
2// realm MINT.md designs end to end. It inherits garden/v4's machinery -- the SaveDay shape,
3// meters, garden plots, levels, and the List/Buy/Cancel trading surface -- but NOT v4's
4// invariants (MINT.md section 6). garden/v4 is live, immutable and untouched by this file; its
5// two gnomes stay exactly where they are forever (MINT.md section 6, BATCH-ONE.md section 0).
6//
7// WHAT CHANGES FROM v4, IN ONE TABLE, EACH CITED TO THE DOCUMENT THAT DECIDED IT:
8//
9// - Adopt(name, hat, coat, eyes, nose) is GONE. Nothing is chosen by a caller any more.
10// DECISIONS.md 2026-10-15 deprecates the DIY picker as a product direction; MINT.md
11// section 1a/section 2 replace it with a roll.
12// - An ALLOWLIST gates who may ever claim (MINT.md section 11: "gate issuance, never the
13// secondary market"). Admin-managed, additive and removable, never touching the mint
14// itself once a claim has spent its roll.
15// - ONE ROLL PER WALLET, FOREVER, enforced independently of whether the resulting gnome is
16// still held (MINT.md section 1a/section 2.3). hasRolled is permanent; nothing in this file
17// clears it, and selling the gnome does not refund the roll.
18// - FREE. Claim refuses coins exactly as v4's Adopt refuses them (MINT.md section 1a/2.5,
19// GAMES.md rule 6: "random when earned, exact when bought... free: random is allowed").
20// - THE ROLL IS TWO-PHASE, SETTLED BY DRAND (MINT.md section 10, the load-bearing finding of
21// this whole file). A single-transaction roll is peekable two ways, verified against
22// gno.land's own source and binary rather than assumed from Ethereum's model: vm/qeval can
23// call a crossing function for free with no signature, and `gnokey maketx call -simulate
24// only` previews a real signed call against the last committed block with no gas and no
25// broadcast. Neither tool is the cause; the cause is that gno.land exposes NO on-chain
26// entropy at all -- chain/runtime has ChainHeight and nothing else, math/rand is PCG seeded
27// (0,0) on every node forever, and there is no shipped VRF -- so any single-transaction
28// formula, however it is dressed up, is something a caller can evaluate offline for every
29// block it might land in before ever deciding to broadcast. The fix MINT.md section 10.5
30// settles on, and this file builds, is a commit/reveal split across this project's own
31// deployed drand verifier: Claim spends the one-time roll and pins a ROUND THAT DOES NOT
32// EXIST YET; Settle, permissionless, computes the result once that round is actually
33// published. One player signature, not two -- the player signs Claim only.
34// - PARTS FROM A WEIGHTED TABLE (ECONOMY.md section 3), not a player's choice, with three
35// named one-of-ones (Hob, Thistle, Wick -- BATCH-ONE.md section 7) capped at one each.
36// - COUNTED RARITY. Every part's population is tracked on chain and readable by anyone --
37// MINT.md section 13's "nine gnomes in the world have this nose" has to be a chain fact,
38// and EyesCount/NoseCount/HatCount/CoatCount below are that fact, not an app claim layered
39// on top.
40// - MINT ONE, HOLD MANY, PLAY ONE (MINT.md section 2a). Buy's one-gnome-per-address refusal is
41// GONE -- deliberately, and only from Buy, never from Claim (see Claim's hasRolled gate,
42// which is the cap that still matters). An address may hold any number of gnomes; exactly
43// one is ever AWAKE, tracked by activeToken, and only the awake one can ever gain a cared
44// day, a streak, a level or XP. SaveDay and ChooseType resolve through activeToken, never
45// through "the" gnome for an address, because there no longer is one.
46// - SWITCHING (Wake) is free, at most once per UTC day (MINT.md section 2a), the same
47// day-boundary discipline SaveDay's own dailyCareDay already uses.
48// - THE SECONDARY MARKET IS OPEN: Buy/List/Cancel carry over v4's shape and fee (MINT.md
49// section 11), with no allowlist on buying and no hold cap.
50// - THE LOCK: the three named one-of-ones cannot be listed until they have lockCaredDays
51// cared days; every ordinary gnome trades from day one (MINT.md section 12/13, "Option A" --
52// lock only the named one-of-ones, recommended there and NOT YET CONFIRMED BY THE OWNER,
53// which is why lockCaredDays is one named constant and nothing else in this file assumes
54// its value). A gnome's own caredDayCount never decreases and is not reset by Buy, so once
55// a named gnome unlocks it stays unlocked through every future sale.
56// - A GENESIS GNOME, minted to this realm's own address at deploy, non-transferable, never
57// listable, separate from the 24-gnome batch (DECISIONS.md 2026-10-15: "the genesis gnome
58// belongs to nobody, permanently, on purpose"). Its name is not yet settled anywhere in this
59// project's documents; genesisGnomeName below is a placeholder, named as one.
60//
61// WHAT IS DELIBERATELY OUT OF SCOPE FOR THIS FILE, EACH A SCOPING DECISION MADE BY THIS PASS,
62// NOT A GAP IN THE BRIEF THAT ASKED FOR IT. Recorded here in the open the same way MINT.md
63// records its own open questions, so a future reader finds the reasoning in one hop:
64//
65// - NO SetRule/FreezeRules TUNABLE ADMIN SURFACE. v4's ruleBand/SetRule/FreezeRules machinery
66// is real and proven, but every care-mechanic number below is a fixed constant at v4's own
67// deploy defaults instead. The brief this file was written against lists eleven concrete
68// requirements and none of them is "tunable rules"; adding v4's whole admin-ratchet surface
69// on top of everything already new here would have roughly doubled this file's size and
70// its audit surface for a feature nobody asked for in this pass. A v6 (or a v5.1 companion
71// design note) can add it back following v4's own pattern exactly if the owner wants it.
72// - NO PLAYER-CHOSEN NAME. v4's name/validNameRune/validNameCharset/hasVisibleNameContent
73// machinery (three audit rounds' worth, per v4's own comment) existed to validate a string
74// nobody in this brief asked this realm to accept any more -- nothing is chosen by a caller
75// at mint time, including a name. A gnome is identified by its tokenID and, for the three
76// one-of-ones, by Character(tokenID); the app is free to let an owner caption their own
77// gnome off chain. Dropping this also removes an entire validation subsystem's audit surface
78// for no loss against the brief.
79// - NO OpenBatch ADMIN SURFACE. MINT.md section 6 floats an owner-callable OpenBatch for
80// adding future batches' rosters; the task this file was built against names a single
81// 24-gnome batch (BATCH-ONE.md) and does not ask for a mechanism to open a second one. The
82// batch here is sized once, at deploy, by the batchCap/numNamedEditions constants below. A
83// garden/v6 is the natural place for OpenBatch if a second batch is ever wanted, the same
84// way every other irreversible-shape decision in this project has gotten its own version
85// rather than a retrofit.
86//
87// A REAL FLAW FOUND AND FIXED WHILE DESIGNING Settle, RECORDED HERE RATHER THAN QUIETLY FIXED
88// AND FORGOTTEN -- the same discipline MINT.md section 10 models for its own single-transaction
89// finding. An earlier version of this design picked the three named one-of-ones by drawing
90// "without replacement" from a shrinking pool: remainingTotal = batchCap - settledCount,
91// remainingNamed = 3 - namedAssignedCount, and a claimant's pick = seed % remainingTotal. THAT
92// IS PEEKABLE AND GAMEABLE, even though the seed itself is honestly drand-derived and
93// unforgeable: because remainingTotal only ever shrinks as OTHER claimants settle, and Settle is
94// permissionless and callable at any later time, a claimant whose seed is already fixed (the
95// moment their pinned round publishes) can simply precompute, for every possible remainingTotal
96// from 1 to batchCap, whether that value would win them a named edition -- and then wait,
97// costing nothing, until the rest of the batch happens to settle into a state that does. The fix
98// below removes every dependency on shrinking, observable, time-varying state from the decision:
99// a claimant's own named-edition pick is `seed % batchCap`, against a modulus that is a
100// COMPILE-TIME CONSTANT, not a moving count, so there is nothing left to wait for -- the only
101// residual order-dependence is which of possibly several colliding claimants (bad luck, not
102// strategy: nobody can change their own pick by choosing when to settle) gets a named edition
103// that more than one of them rolled, and that is resolved by whichever Settle call lands on
104// chain first.
105//
106// A SECOND AUDIT PASS NAMED THIS PRECISELY: "settling promptly", said above and in an earlier
107// version of Settle's own comment, OVERSTATES what the residual actually rewards. It is not
108// "whoever claimed first" or "whoever cared most" -- pendingClaim.claimedAt is never consulted
109// by the tie-break, and Settle is permissionless, so a bystander (a searcher, a bribed relayer,
110// a claimant's own bot) can win a collision for whichever side pays for transaction-ordering
111// priority, regardless of who rolled or claimed first. Considered and rejected: tie-breaking on
112// claimedAt instead. It sounds like the honest fix, but it is not reachable without a worse
113// trade-off -- Settle finalizes irrevocably (it mints a real token to a real owner, who may
114// already have acted on it), so honoring an earlier claimedAt would mean either refusing to
115// finalize ANY collision candidate until every possible earlier claimant's round has published
116// and settled (reintroducing exactly the wait-and-see window section 10 and this fix exist to
117// close, now around a deadline instead of a pool size) or reaching back to UNDO an already-
118// settled mint (which nothing in this realm, or any grc721-based realm in this project, has ever
119// claimed to be able to do, and which would be a strictly worse property than the one being
120// fixed). What actually holds, stated precisely rather than aspirationally: nobody can improve
121// THEIR OWN odds by choosing when to settle (the property section 10 is about, and the property
122// this fix actually guarantees); the residual is an ordinary transaction-ordering race over a
123// LOW-PROBABILITY, LOW-STAKES event (three slots in a batch of 24, decided among whichever
124// handful of claimants happen to collide), the same shape of race every permissionless action on
125// every chain is already subject to, not a new hole this design opened. See Settle's own comment
126// for the arithmetic.
127//
128// SETTLE CAN NOW PAY FOR ITS OWN BEACON RELAY, OWNER-APPROVED 2026-10-15, THE DAY AFTER THIS FILE
129// WAS FIRST WRITTEN. The two-phase roll above depends on beacon/v2 actually having the pinned
130// round's drand signature by the time Settle runs, and until this change that was somebody
131// else's problem: a relay has to happen, by a human or a bot, running outside this realm and
132// outside this project's control, or a claimant who has already spent their one-and-only roll is
133// stuck mid-mint through no fault of their own -- found live, not hypothetically, while building
134// this file: the project's own beacon was observed ~2 days stale. The fix the owner asked for
135// ("can we have the mint transaction pay the beacon though") is `SettleWithSignature` below: the
136// player's own app fetches the pinned round's signature from drand's public API (the same
137// `https://api.drand.sh/<chain>/public/<round>` this file's own test fixtures are fetched from)
138// and passes it straight through; Settle relays it itself, in the same transaction, before
139// computing the result. One transaction, and the player pays its gas -- TO THE NETWORK, same as
140// any other transaction's gas, NOT to this project, so it never touches the free-mint promise
141// (MINT.md section 14: "gas is still a payment, just not to us... the operator takes no stake").
142// Three things this buys, in the owner's own order of importance:
143//
144// - IT COSTS THE PROJECT NOTHING. The alternative to a player-paid relay is an operator-run one
145// -- a cron job or a bot calling beacon.Publish on a schedule, which is real recurring GNOT
146// spend with no revenue to offset it (MINT.md section 14 forecloses an unlock fee, batch one
147// is free end to end). This change deletes that cost rather than budgeting for it.
148// - A PLAYER CAN NEVER BE BLOCKED BY THIS PROJECT'S OWN HOUSEKEEPING. The stuck-mid-mint state
149// above is now unreachable for anyone willing to fetch their own round's signature: their own
150// Settle call makes the round available, with no dependency on this project, a third-party
151// relay, or anyone else's goodwill.
152// - THE BEACON GETS FRESHER AS A SIDE EFFECT, FOR EVERYONE ELSE USING IT. beacon/v2 is a shared,
153// permissionless, on-demand record (see its own package doc) -- a round this realm's own
154// claimants cause to be published is published for every other consumer of that beacon too,
155// not just for this realm.
156//
157// THREE THINGS WORKED OUT BY QUERYING THE LIVE CHAIN, NOT ASSUMED, because this is new
158// cross-realm write surface and this project's own history (the SPLASH beacon migration,
159// MEMORY.md) is built from exactly the kind of interrealm detail that gets guessed wrong:
160//
161// 1. WHAT beacon.Publish DOES ON AN ALREADY-PUBLISHED ROUND. Fetched byte-for-byte from the live
162// deployed realm itself (gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/beacon/v2) via
163// the public onyx-1 RPC's `vm/qfile` query, 2026-10-15, and diffed identical against this
164// project's own vendored copy at realms/_vendor/beacon/v2/beacon.gno -- it PANICS, it does not
165// no-op: `func Publish(cur realm, round int64, sigHex string) { if Published(round) { panic
166// ("beacon: round already published") } ... }`. SettleWithSignature therefore checks
167// `beacon.Published` itself BEFORE ever calling Publish, exactly once, and never calls Publish
168// at all if the round is already there -- calling it unconditionally would make this function
169// fail every time a relay (another claimant's own SettleWithSignature, or anyone else) beat it
170// to the punch, which given Settle's own permissionless, callable-by-anyone design is an
171// ordinary and expected race, not an edge case.
172// 2. THAT Publish IS REACHABLE CROSS-REALM AS `beacon.Publish(cross(cur), round, sigHex)`, and
173// that it carries no caller-identity check at all for this function to trip over. Confirmed
174// two ways: the live source pulled in point 1 shows Publish's body never reads `cur` or calls
175// `cur.Previous()` -- it is permissionless by whoever relays, not merely by whichever address
176// -- and this exact call shape is already proven working, not merely plausible, by this
177// file's OWN test harness: garden_test.gno's `publishAs` helper has called
178// `beacon.Publish(cross(cur), round, sigHex)` from this package since the day this file was
179// written, and every test that calls it passes. SettleWithSignature's own call is the
180// identical shape with `cur` being Settle's own realm parameter instead of a test helper's.
181// (`cur.Previous()` inside beacon.Publish would resolve to THIS realm, garden/v5, when called
182// this way rather than to the human wallet that called Settle -- a real interrealm fact worth
183// recording even though Publish does not currently branch on it: if beacon/v2 ever grows a
184// caller-identity check, re-read this paragraph before assuming Settle's relay still works.)
185// 3. GAS. Measured on this toolchain with r/splash/beacon/zz_gas_test.gno's own method
186// (`gno test -run TestZZGas -print-runtime-metrics=true -v`, net of this package's own fixed
187// per-test-call overhead): a bare Settle today costs ~6.2M gas; beacon.Publish alone costs
188// ~18.5M gas on THIS realm's measurement (the same order of magnitude as beacon/v2's own
189// documented 16,970,375, re-measured rather than quoted because toolchain and realm storage
190// both shift it). SettleWithSignature's worst case -- the round not yet published at all --
191// is therefore on the order of ~25M gas total, call it 1% of the 3e9 block gas limit and a
192// few hundredths of a GNOT at this chain's gas price. Reasonable: it is roughly four times a
193// bare Settle, not an order of magnitude more, it still leaves room for well over a hundred
194// such settlements in a single block, and it replaces a cost this project would otherwise have
195// paid itself, recurringly, forever, to keep the beacon fresh by some other means.
196//
197// THE SHAPE: TWO ENTRY POINTS, ONE SHARED CORE, NOT ONE FUNCTION WITH AN OPTIONAL SIGNATURE.
198// `Settle(cur realm, claimant address)` is UNCHANGED in behaviour and UNCHANGED in signature --
199// every existing test and every existing caller that already knows (or does not care) whether the
200// round is published keeps working exactly as before, at exactly its old gas cost, with no
201// signature to fetch or supply. `SettleWithSignature(cur realm, claimant address, sigHex string)`
202// is new, and is for a caller that HAS a signature and wants its transaction to publish the round
203// if nobody else already has. A Gno realm's exported functions take a fixed argument list -- there
204// is no overloading, and an optional/variadic last argument would still force every caller,
205// including every test and the app's own simpler "it's already published" path, to pass an empty
206// string they have no use for, for a case (publishing) that is the EXCEPTION, not the rule, since
207// most settlements land after some earlier claimant or relay has already published the shared
208// round. Two named entry points make the exception visible in the function name instead of buried
209// in an argument, and cost nothing extra: both share one unexported `settleCore`, so there is
210// exactly one copy of the draw-and-mint logic, not two drifting in parallel. And it is SAFE for
211// either one to be called in either actual on-chain state: Settle still refuses cleanly, with its
212// original message, if the round genuinely is not published yet; SettleWithSignature still
213// succeeds, with its supplied signature simply going unused, if the round turns out to already be
214// published by the time it runs (point 1's race) -- neither caller has to know in advance which
215// state they are in, they only have to know whether they personally HAVE a signature to offer.
216package garden
217
218import (
219 "strconv"
220 "strings"
221 "time"
222
223 "gno.land/p/nt/avl/v0"
224 "gno.land/p/nt/grc721/v0"
225 "gno.land/p/nt/seqid/v0"
226
227 "chain"
228 "chain/banker"
229 "chain/runtime/unsafe"
230
231 beacon "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/beacon/v2"
232)
233
234// ============================================================================
235// THE THREE SALE CONSTANTS. CARRIED OVER, UNCHANGED, FROM garden/v4's 2026-10-11 DECISION, AS
236// PLACEHOLDERS FOR THIS NEW REALM. Nobody has asked the owner to re-confirm these specifically
237// for v5 -- re-decide or re-confirm them before ever deploying this file; see gnomod.toml's own
238// checklist item 0. init() below still gates them exactly as v4's assertFeeConfig does, so an
239// edit to a value that cannot work fails at deploy rather than succeeding silently forever.
240// ============================================================================
241const (
242 saleFeeRecipient = "g1ylpe9juk8cafgf9rt09wzuz85ug2lyfk5metyv" // gnotchi-opponent, v4's choice
243 saleFeeRateBps = int64(500) // 5% of the declared price
244 saleFeeFloorUgnot = int64(1_000_000) // 1 GNOT flat minimum per sale
245)
246
247// ============================================================================
248// Constants that are NOT tunable, and never will be: see the package doc's "out of scope"
249// section for why v5 has no SetRule surface at all.
250// ============================================================================
251
252const (
253 collectionName = "Gnotchi gnomes"
254 collectionSymbol = "GNOME"
255
256 // adminAddress is the only address AdminAllow/AdminDisallow will accept. Compiled in, with
257 // no transfer function, for the same reason v4 has none: an admin-transfer path is a second
258 // authority path, and a lost admin key merely freezes the allowlist, which is recoverable by
259 // redeploying a v6 -- not a disaster.
260 //
261 // THIS IS THE ONYX TESTNET DEPLOY KEY `gnotchi-deploy`, which is also the namespace this
262 // realm deploys under (see gnomod.toml) and the only key on this host with enough GNOT to
263 // pay for the admin calls -- AdminAllow and Settle both cost fees, and v4's old key
264 // (g1r4aw43..., the retired splash-deploy) is down to dust, so compiling that address in
265 // here would have produced an admin that could not afford to act. Correct for onyx, wrong
266 // for anywhere real. Change it before deploying anywhere that matters; see gnomod.toml.
267 adminAddress = "g1nq99m3h9ed7vqve6ajpr3qwy2ls252m8jnundm"
268
269 // numEyes / numNoses / the hat and coat catalogues: THE SAME FOUR-DIMENSIONAL FACE SPACE
270 // garden/v4 uses (SPRITES.md's Phase 1: 10 eyes x 8 noses x 4 hats x 4 coats = 1,280 looks).
271 // Nothing here is a new catalogue; eyesWeights/noseWeights below are a weighted DRAW over
272 // the same ids v4 already validates, per ECONOMY.md section 3 and MINT.md section 13's
273 // counted-rarity decision ("no tiers... rarity becomes population frequency counted on
274 // chain"). hat and coat are drawn UNIFORMLY (25% each): ECONOMY.md section 3 only specifies
275 // weights for the "permanent pair" (eyes, nose), and BATCH-ONE.md section 4a says so
276 // explicitly -- "assigning the section 3 percentages to hat and coat as well... is real work
277 // this document does not do... flagged here as the next thing to specify". A uniform draw is
278 // the honest placeholder until that work happens, not a guess dressed up as one.
279 numEyes = int64(10)
280 numNoses = int64(8)
281
282 // hourSeconds/daySeconds: unchanged from v4.
283 hourSeconds = int64(3600)
284 daySeconds = int64(86400)
285
286 // ---- care-mechanic constants, fixed at v4's own deploy DEFAULTS (v4's ruleBands table).
287 // See the package doc: v5 deliberately carries no tunable-rules admin surface.
288 numPlots = 3 // acorns.plots
289 plantCost = int64(1) // acorns.plantCost
290 feedCost = int64(1) // acorns.feedCost
291 acornsStart = int64(10)
292 meterMax = int64(10)
293 patCheer = int64(3)
294 hungryAtOrBelow = int64(2)
295
296 growHours = int64(24)
297 patCooldownHours = int64(12)
298 tinkerCost = int64(5)
299 levelBase = int64(50)
300 levelStep = int64(25)
301 sulkyPenaltyPct = int64(10)
302 harvestPerPlot = int64(3)
303 feedBelly = int64(4)
304 bellyDropEveryHours = int64(3)
305 cheerDropEveryHours = int64(6)
306 dailyCareCap = int64(60)
307
308 // acornReserve: v4's fix for the acorn softlock (AUDIT-DEPLOYED.md G9), carried over
309 // unchanged. See v4's own constant for the full argument; the short version is that a
310 // SaveDay batch that spends acorns may never end with fewer than this and nothing planted.
311 acornReserve = plantCost + feedCost
312
313 happyBonusPct = int64(10)
314 happyWindowHours = int64(2)
315 sulkyAfterHours = int64(72)
316 maxLevel = int64(20)
317 typeAtLevel = int64(5)
318
319 xpPat = int64(5)
320 xpFeed = int64(5)
321 xpHarvestPerPlot = int64(10)
322 xpTinkerTask = int64(15)
323
324 maxActionsPerCall = 32
325 maxBackfillHours = int64(48)
326 caredWindowDays = uint(64)
327
328 maxGnomesPageSize = 100
329 maxListingsPageSize = 100
330
331 // ---- the sale. Shape constants, unchanged from v4.
332 ugnotDenom = "ugnot"
333 maxPriceUgnot = int64(1_000_000_000_000_000)
334 maxFeeRateBps = int64(10_000)
335 maxListingDays = int64(30)
336
337 moodHungry = "hungry"
338 moodSulky = "sulky"
339 moodHappy = "happy"
340 moodContent = "content"
341
342 actionPat = "pat"
343 actionFeed = "feed"
344 actionPlant = "plant"
345 actionHarvest = "harvest"
346 actionTinker = "tinker"
347
348 typeGardener = "gardener"
349 typeExplorer = "explorer"
350 typeTinkerer = "tinkerer"
351
352 // ============================================================================
353 // THE MINT: allowlist, one roll per wallet, the batch, the lock, the drand pin. Every
354 // number below is a single named constant, each trivially changeable, per the brief's own
355 // instruction for the lock specifically and applied here to the pin offset too.
356 // ============================================================================
357
358 // batchCap: BATCH-ONE.md's own roster -- 24 gnomes, total, forever, this batch. 21 ordinary
359 // plus three named one-of-ones (numNamedEditions below). Closed the instant mintedCount
360 // reaches this; nothing in this file can ever raise it (no OpenBatch -- see the package
361 // doc's "out of scope" section).
362 batchCap = int64(24)
363
364 // numNamedEditions: Hob, Thistle and Wick (namedEditionOrder below), cap ONE EACH. "Cap 1" is
365 // not a different mechanism from any other cap, MINT.md section 3's own point -- it is the
366 // same characterOwner.Has check any other population cap would use, just at the number one.
367 numNamedEditions = 3
368
369 // lockCaredDays: MINT.md section 12/13's RECOMMENDATION, explicitly not yet confirmed by the
370 // owner ("Option A... this is a recommendation, not a decision"). The three named one-of-ones
371 // cannot be listed until they have this many cared days; every ordinary gnome trades from
372 // day one. One named constant, nothing else in this file assumes 30 specifically.
373 lockCaredDays = int64(30)
374
375 // pinOffsetSeconds / pinOffsetRounds: how far into drand's future Claim pins a round, sized
376 // AS SECONDS, from drand evmnet's own period (beacon.PeriodSeconds, re-exported from this
377 // project's own bls254 package, not restated here), per MINT.md section 10.5's explicit
378 // instruction -- never as a round count checked only against a block count, which is the
379 // exact dimensional trap this project's own history (the SPLASH raffle shipping with a 100x
380 // wrong round/block ratio, MEMORY.md) says cost real time once already. 60 seconds is twenty
381 // drand rounds at the 3-second period CurrentRound() at Claim time is comfortably able to
382 // pin as "not yet signed by anyone, including drand itself" -- see Claim's own comment for
383 // why the margin matters even though CurrentRound() is pure arithmetic over block time, not
384 // a relay-dependent estimate.
385 pinOffsetSeconds = int64(60)
386)
387
388// pinOffsetRounds is a const expression over an imported const (beacon.PeriodSeconds), which Gno
389// permits across package boundaries exactly as it would within one; TestPinOffsetIsTwentyRounds
390// pins the arithmetic rather than trusting the comment above.
391const pinOffsetRounds = pinOffsetSeconds / beacon.PeriodSeconds
392
393// kindEnd-style ratchet machinery: deliberately absent. See the package doc.
394
395// hatOptions / coatOptions: v4's validHats/validCoats catalogue, as an ORDERED array rather than
396// a set, because Settle needs to draw a POSITION, not merely validate one. Order is frozen the
397// moment this realm deploys, exactly as v4's own numEyes/numNoses ordering is frozen -- a part
398// may be redrawn; it may never be reordered, removed or renumbered (v4's own comment on this).
399var hatOptions = [4]string{"red", "purple", "green", "navy"}
400var coatOptions = [4]string{"blue", "teal", "orange", "brown"}
401
402// eyesWeights: ECONOMY.md section 3, verbatim -- "eyes across 4 colours at 15%, 3 at 9%, 2 at
403// 5%, 1 at 3%" -- 4+3+2+1 = 10 entries = numEyes, and 4*15+3*9+2*5+1*3 = 100.
404// TestWeightTablesSumToOneHundred pins the sum rather than trusting the arithmetic in this
405// comment; drawWeighted's own fallback additionally makes a drift here fail safe, not silently.
406var eyesWeights = [10]int64{15, 15, 15, 15, 9, 9, 9, 5, 5, 3}
407
408// noseWeights: ECONOMY.md section 3 says only "nose similar across 8 shapes" and gives no exact
409// percentages -- BATCH-ONE.md section 4a names this directly as real work nobody has done yet.
410// This is a SHAPE-FAITHFUL INTERPRETATION, not a transcription: four tiers of two shapes each,
411// 20/20/15/15/10/10/5/5, the same big-gap-then-smaller-gaps shape eyesWeights has, scaled to 8
412// entries and summing to 100. Flagged here, not quietly presented as sourced, so the owner or a
413// future author can replace it with real numbers the moment ECONOMY.md section 3 gets them.
414var noseWeights = [8]int64{20, 20, 15, 15, 10, 10, 5, 5}
415
416// namedEditionOrder: BATCH-ONE.md section 7's three names, in the fixed order Settle's
417// namedPick indexes into. The order carries no meaning -- it is not a ranking -- it only has to
418// be fixed, which it is, forever, the moment this realm deploys.
419var namedEditionOrder = [numNamedEditions]string{"hob", "thistle", "wick"}
420
421// genesisGnomeName: DECISIONS.md 2026-10-15 settles the MECHANISM (minted to this realm's own
422// address, non-transferable, never listable, separate from the batch) but never settles a NAME
423// anywhere in this project's documents. Placeholder, named as one; change it freely before
424// deploying, it is read by nothing else in this file.
425const genesisGnomeName = "Genesis"
426
427// ============================================================================
428// Storage
429// ============================================================================
430
431// plot: unchanged from v4. plantedAt == 0 is the empty state; readyAt is stored, not computed,
432// so a future change to growHours (there is none possible in this file, but the shape is kept
433// for the day a v6 adds tuning back) could never reach backwards into an already-planted plot.
434type plot struct {
435 plantedAt int64
436 readyAt int64
437}
438
439// gnome is the whole persisted pet. EVERY FIELD HERE HAS AN EXPORTED READER -- v4's own rule,
440// carried over verbatim: a realm cannot grow a second chance at this after deploy.
441//
442// NOT HERE, DELIBERATELY: a name (see the package doc's "out of scope" section) and a hat/coat
443// the OWNER chose (nothing is chosen any more; hat/coat are ROLLED exactly as eyes/nose are,
444// and are exactly as permanent as eyes/nose always were in v4 -- there has never been a function
445// anywhere in this family of realms that changes a gnome's hat or coat after mint).
446//
447// character is "" for every ordinary gnome and one of namedEditionOrder's three strings for a
448// named one-of-one. eyes/nose/hat/coat are the sentinel -1/-1/""/"" for a named edition: its
449// look is NOT a point in the ordinary 1,280-look space, it is BATCH-ONE.md section 6's own new
450// art (Hob's crown and gold coat, Thistle's helmet and axe, Wick's lantern), which this contract
451// does not need to know the pixels of -- only that Character(tokenID) names it, so the app can
452// render the right thing. A named edition's parts are therefore never counted in
453// eyesCounts/noseCounts/hatCounts/coatCounts: it is already as rare as a thing can be, at cap
454// one, counted instead through characterOwner.
455type gnome struct {
456 hat string
457 coat string
458 eyes int64
459 nose int64
460 character string
461
462 level int64
463 gnomeType string
464 adoptedAt int64 // unix seconds; the moment Settle minted this gnome, or init() for genesis
465
466 belly int64
467 bellyAt int64
468 cheer int64
469 cheerAt int64
470
471 lastPatAt int64
472 lastHappyEventAt int64
473
474 totalXp int64
475
476 xpGarden int64
477 xpTinker int64
478 harvestCount int64
479
480 dailyCareXp int64
481 dailyCareDay int64
482
483 plots [numPlots]plot
484
485 lastTinkerAt int64
486
487 // Cared days. v4's own shape, unchanged: the chain stores FACTS (which UTC days were
488 // cared for), never the STREAK RULE, which stays in app/js/rules.js's streakFrom. See
489 // v4's gnome struct comment for the full argument; it applies here without edit.
490 caredDayCount int64
491 lastCaredDay int64
492 caredMask uint64
493
494 lastActionAt int64
495
496 // lastWokenAt: unix seconds, the moment THIS token last became somebody's active gnome --
497 // set at mint (settleCore), at purchase (Buy), and at every explicit Wake, always to "now" at
498 // that moment, never anything earlier. SaveDay's backfill floor is max(lastActionAt,
499 // lastWokenAt, now-48h): a token cannot be credited for any period before it was actually the
500 // active one, closing the gap ATTACK-REVIEW.md goal 7 found -- v4 never needed this field
501 // because v4 enforced one gnome per address, so there was no second token to backfill against
502 // while this one slept. See SaveDay's own comment for the full argument.
503 lastWokenAt int64
504
505 acquiredAt int64
506 saleCount int64
507}
508
509// listing: unchanged in shape from v4, now simply one of possibly SEVERAL a single owner may
510// have standing at once (mint one, hold many) -- listings is still keyed by tokenID, which was
511// always the right key for this and needed no change.
512type listing struct {
513 seller address
514 priceUgnot int64
515 buyer address
516 listedAt int64
517 expiresAt int64
518}
519
520// feeConfig: unchanged from v4.
521type feeConfig struct {
522 recipient address
523 rateBps int64
524 floorUgnot int64
525}
526
527// pendingClaim is the record Claim writes and Settle consumes: the permanent, irrevocable fact
528// that this address spent its one roll, and the future drand round it is waiting on.
529type pendingClaim struct {
530 round int64
531 claimedAt int64
532}
533
534var (
535 token *grc721.Token
536 ledger *grc721.PrivateLedger
537
538 nextID seqid.ID
539
540 gnomes = avl.NewTree() // tokenID -> *gnome
541
542 // ownerTokens: owner address -> *[]string, every tokenID that address currently holds.
543 // MINT.md section 2a's "hold many": v4 never needed this because v4 never let an address
544 // hold more than one. See addOwnerToken/removeOwnerToken.
545 ownerTokens = avl.NewTree()
546
547 // activeToken: owner address -> tokenID string, the ONE gnome that is awake for that
548 // address right now. Absent means "nothing awake" -- reachable only for an address that has
549 // never settled a claim or bought a gnome, or that has sold its only awake gnome and not yet
550 // woken another (Buy deliberately does not auto-promote a replacement; see Buy's comment).
551 activeToken = avl.NewTree()
552
553 // lastWakeDay: owner address -> int64 UTC day number, the once-per-day gate on Wake.
554 lastWakeDay = avl.NewTree()
555
556 // lastCaredDayForOwner: owner address -> int64 UTC day number, the most recent calendar day
557 // on which ANY ONE of this owner's tokens has already banked a cared day. The package doc is
558 // explicit and unconditional: "exactly one is ever AWAKE... and only the awake one can ever
559 // gain... a cared day" (see this file's own doc comment). lastWokenAt (see the gnome struct)
560 // closes the BACKFILL half of ATTACK-REVIEW.md goal 7 -- a token cannot backfill a day it
561 // spent asleep. This tree closes the LIVE half: without it, an owner could Wake(B) partway
562 // through a UTC day already spent genuinely caring for A, then genuinely care for B too,
563 // using only real-time, non-backfilled timestamps, and bank the SAME calendar day on BOTH --
564 // no backfill panic ever fires, because neither action is backfilled, each is simply real
565 // care given to whichever gnome was actually awake at the time. That still doubles the one
566 // property this bookkeeping exists to measure (lockCaredDays' 30-day resale lock, and the
567 // public cared-days streak), for one wallet, once per day, for as many tokens as the owner is
568 // willing to switch through -- the exact harm goal 7 names, reached by a second, undiscovered
569 // mechanism. recordCaredDay only ever runs, and this tree only ever advances, when the day
570 // being recorded is NEWLY banked for this OWNER, not merely newly banked for this TOKEN.
571 lastCaredDayForOwner = avl.NewTree()
572
573 balances = avl.NewTree() // owner address -> int64 acorns, per PLAYER, exactly as v4
574
575 listings = avl.NewTree() // tokenID -> *listing
576
577 // allowlist: owner address -> true. Admin-managed (AdminAllow/AdminDisallow), gates Claim
578 // only -- MINT.md section 11: "gate issuance, never the secondary market". Buying and
579 // selling need no entry here at all.
580 allowlist = avl.NewTree()
581
582 // hasRolled: owner address -> true, PERMANENT, never removed by anything in this file. "One
583 // roll per wallet, forever" (MINT.md section 1a/2.3), independent of whether the resulting
584 // gnome is still held, independent of whether the address is later removed from the
585 // allowlist. Disallowing an address after it has rolled does not and cannot un-roll it.
586 hasRolled = avl.NewTree()
587
588 // pendingClaims: owner address -> *pendingClaim. Written by Claim, removed by Settle. At
589 // most one per address, because hasRolled already refuses a second Claim from the same one.
590 pendingClaims = avl.NewTree()
591
592 // eyesCounts / noseCounts / hatCounts / coatCounts: counted rarity (MINT.md section 13).
593 // Keyed by the part's own id/name, value is a lifetime population count, incremented only by
594 // an ORDINARY Settle (never by a named one-of-one, which is counted separately below, and
595 // never decremented: there is no burn, and a sale moves a part without minting one).
596 eyesCounts = avl.NewTree()
597 noseCounts = avl.NewTree()
598 hatCounts = avl.NewTree()
599 coatCounts = avl.NewTree()
600
601 // characterOwner: one of namedEditionOrder's three names -> tokenID, present only once that
602 // edition has actually been minted. Has(name) is both "has this cap-one edition been taken"
603 // (Settle's own fallback check) and "does it exist yet" (CharacterOwner's reader) -- the same
604 // single tree answers both questions, which is why there is no separate counter for it.
605 characterOwner = avl.NewTree()
606
607 mintedCount int64 // reservations made by Claim, gates batchCap; never decremented
608 settledCount int64 // completions made by Settle; informational only, see BatchStatus
609
610 genesisTokenID string // set exactly once, in init()
611
612 rulesFrozen = true // always true: there are no rules to freeze or unfreeze in v5. See AdminState.
613
614 fee feeConfig
615
616 transferGate string
617)
618
619func init(cur realm) {
620 if !address(adminAddress).IsValid() {
621 panic("garden: adminAddress is not a valid address; fix the constant before deploying")
622 }
623
624 cfg := feeConfig{
625 recipient: address(saleFeeRecipient),
626 rateBps: saleFeeRateBps,
627 floorUgnot: saleFeeFloorUgnot,
628 }
629 assertFeeConfig(cfg, cur.Address())
630 fee = cfg
631
632 token, ledger = grc721.NewToken(collectionName, collectionSymbol, 0, cur)
633 ledger.RegisterExtension(&saleGateExtension{})
634
635 // THE GENESIS GNOME. Minted here, directly, to this realm's own address -- never through
636 // Claim, never counted against batchCap, never allowlist-gated, because it is not a roll and
637 // not part of the batch (DECISIONS.md 2026-10-15). No wallet holds this realm's private key,
638 // so no transaction can ever be signed as its owner: List, Buy and Wake all additionally
639 // refuse genesisTokenID by name as a second, redundant lock, and saleGateExtension's
640 // OnTransfer refuses it unconditionally as a third -- the same "two locks on a door that
641 // already has one" discipline v4's own sale gate uses, because the claim "belongs to
642 // nobody" is only as strong as the weakest reachable path around it.
643 id := nextID.Next()
644 genesisTokenID = id.String()
645 me := cur.Address()
646 if err := ledger.Mint(me, grc721.TokenID(genesisTokenID)); err != nil {
647 panic(err)
648 }
649 now := time.Now().Unix()
650 gnomes.Set(genesisTokenID, &gnome{
651 character: "genesis",
652 eyes: -1,
653 nose: -1,
654 level: 1,
655 adoptedAt: now,
656 belly: meterMax,
657 bellyAt: now,
658 cheer: meterMax,
659 cheerAt: now,
660 lastActionAt: now,
661 acquiredAt: now,
662 })
663 addOwnerToken(me, genesisTokenID)
664
665 // Registered under its own name, "genesis", in the SAME tree the three named one-of-ones
666 // use -- not because it competes with their cap (BatchStatus's namedMinted count iterates
667 // namedEditionOrder only, which "genesis" is never a member of, so this cannot affect the
668 // batch), but so CharacterOwner("genesis") resolves instead of silently reporting not-found
669 // for the one named character that is neither ordinary nor one of the three. Second-audit-
670 // pass finding (G7): fixed here, not left as a reader inconsistency.
671 characterOwner.Set("genesis", genesisTokenID)
672
673 chain.Emit("Genesis", "tokenId", genesisTokenID, "name", genesisGnomeName)
674}
675
676// assertFeeConfig: unchanged from v4's own gate.
677func assertFeeConfig(cfg feeConfig, realmAddr address) {
678 if !cfg.recipient.IsValid() {
679 panic("garden: DEPLOY GATE: saleFeeRecipient is not a valid address; fix the constant at the top of garden.gno before deploying")
680 }
681 if cfg.recipient == realmAddr {
682 panic("garden: DEPLOY GATE: saleFeeRecipient is this realm's own address, and coins sent there are burned because there is no withdrawal path")
683 }
684 if cfg.recipient == address(adminAddress) {
685 panic("garden: DEPLOY GATE: saleFeeRecipient is the admin/deploy key; the fee recipient must be an address chosen for that purpose, not the key that happens to deploy")
686 }
687 if cfg.rateBps < 1 || cfg.rateBps > maxFeeRateBps {
688 panic("garden: DEPLOY GATE: saleFeeRateBps must be between 1 and " + strconv.FormatInt(maxFeeRateBps, 10) + " basis points; fix the constant at the top of garden.gno before deploying")
689 }
690 if cfg.floorUgnot < 1 || cfg.floorUgnot > maxPriceUgnot {
691 panic("garden: DEPLOY GATE: saleFeeFloorUgnot must be between 1 and " + strconv.FormatInt(maxPriceUgnot, 10) + " ugnot; fix the constant at the top of garden.gno before deploying")
692 }
693}
694
695// saleGateExtension: v4's shape, plus the genesis refusal. Panics on every transfer except the
696// one Buy is performing right now, and ALWAYS panics for genesisTokenID regardless of
697// transferGate -- there is no path, including a future bug in Buy, by which the genesis gnome
698// should ever be transferable, so this checks it first and unconditionally.
699type saleGateExtension struct{}
700
701func (saleGateExtension) ExtensionKind() string { return "sale-gate" }
702
703func (saleGateExtension) OnMint(to address, tid grc721.TokenID) {}
704
705func (saleGateExtension) OnTransfer(from, to address, tid grc721.TokenID) {
706 if tid.String() == genesisTokenID {
707 panic("garden: the genesis gnome belongs to nobody and can never change hands")
708 }
709 if transferGate == "" || transferGate != tid.String() {
710 panic("garden: a gnome can only change hands through Buy; every other transfer is permanently disabled")
711 }
712}
713
714func (saleGateExtension) OnBurn(tid grc721.TokenID) {
715 panic("garden: gnomes cannot be burned")
716}
717
718// ============================================================================
719// Admin: the allowlist. The only admin-reachable state in this whole realm -- there is no
720// SetRule, no FreezeRules, no fee steward, no treasury reach, nothing else at all.
721// ============================================================================
722
723// AdminAllow adds addr to the allowlist, so it may call Claim. Adding an address a second time
724// is a harmless no-op. Adding an address that has already rolled changes nothing -- hasRolled is
725// checked independently and is permanent regardless of allowlist state.
726func AdminAllow(cur realm, addr address) {
727 if !unsafe.OriginSend().IsZero() {
728 panic("garden: admin calls accept no coins")
729 }
730 caller := cur.Previous().Address()
731 if caller != address(adminAddress) {
732 panic("garden: only the admin may manage the allowlist")
733 }
734 if !addr.IsValid() {
735 panic("garden: not a valid address")
736 }
737 allowlist.Set(addr.String(), true)
738 chain.Emit("AdminAllow", "admin", caller.String(), "address", addr.String())
739}
740
741// AdminDisallow removes addr from the allowlist. It does NOT, and cannot, undo a roll already
742// spent: hasRolled is a separate, permanent record, and this function never touches it. Removing
743// an address that was never allowed, or that has already claimed, is a harmless no-op.
744func AdminDisallow(cur realm, addr address) {
745 if !unsafe.OriginSend().IsZero() {
746 panic("garden: admin calls accept no coins")
747 }
748 caller := cur.Previous().Address()
749 if caller != address(adminAddress) {
750 panic("garden: only the admin may manage the allowlist")
751 }
752 allowlist.Remove(addr.String())
753 chain.Emit("AdminDisallow", "admin", caller.String(), "address", addr.String())
754}
755
756// ============================================================================
757// Claim: the one roll. Phase one of two. MINT.md section 10.5 step 1.
758// ============================================================================
759
760// Claim spends the caller's one-and-only roll on this realm and pins a drand round that does
761// not exist yet. It mints NOTHING -- Settle does that, once the pinned round is actually
762// published -- because the whole point of this split is that the result must not be computable,
763// by anyone, including this transaction's own caller, at the moment the roll becomes irrevocable
764// (MINT.md section 10.2: gno.land has no on-chain entropy at all, so anything decidable inside
765// one transaction is decidable by the caller before broadcasting it).
766//
767// WALLET-ONLY, the same reasoning as v4's Adopt: a gnome minted to a realm's address could never
768// be recovered, so a batching, sponsor or multicall realm must never be able to spend a roll on
769// anyone's behalf. The `gnokey maketx run` shape is refused by the identical gate, for the
770// identical reason (IsUserCall is false for both).
771func Claim(cur realm) {
772 if !cur.Previous().IsUserCall() {
773 panic("garden: claim must be called directly by a wallet, not through another realm; the one roll this spends could never be returned to an address that could not hold the resulting gnome")
774 }
775 if !unsafe.OriginSend().IsZero() {
776 panic("garden: claim accepts no coins; the mint is free, exactly as garden/v4's adopt already refuses coins, and nothing here can return them")
777 }
778
779 caller := cur.Previous().Address()
780 if !caller.IsValid() {
781 panic("garden: claim requires a valid caller address")
782 }
783
784 if !allowlist.Has(caller.String()) {
785 panic("garden: this address is not on the allowlist; ask the owner to add it before claiming")
786 }
787
788 if hasRolled.Has(caller.String()) {
789 panic("garden: this address has already rolled on this realm; one roll per wallet, forever, whether or not the resulting gnome is still held")
790 }
791
792 if mintedCount >= batchCap {
793 panic("garden: batch one is closed; all " + strconv.FormatInt(batchCap, 10) + " editions have already been claimed")
794 }
795
796 // CurrentRound() is pure arithmetic over THIS call's own block time (RoundAt(time.Now())),
797 // not an extrapolation from however recently somebody last relayed -- see MINT.md section
798 // 10.4's own live measurement of EstimatedLatestRound() erroring under a stale relay while
799 // CurrentRound() kept working regardless. Pinning pinOffsetRounds past it is therefore safe
800 // against relay staleness; the margin exists only for ordinary block-time/wall-clock skew.
801 round := beacon.CurrentRound() + pinOffsetRounds
802
803 // ---- commit. Nothing below this line can fail. ----
804 hasRolled.Set(caller.String(), true)
805 mintedCount++
806 pendingClaims.Set(caller.String(), &pendingClaim{round: round, claimedAt: time.Now().Unix()})
807
808 chain.Emit("Claim", "claimant", caller.String(), "round", strconv.FormatInt(round, 10))
809}
810
811// ============================================================================
812// Settle: permissionless. Phase two of two. MINT.md section 10.5 step 2. Two entry points, one
813// shared core -- see the package doc's "SETTLE CAN NOW PAY FOR ITS OWN BEACON RELAY" section for
814// the full account of why, what it costs, and the two live-chain facts (beacon.Publish panics
815// rather than no-ops on an already-published round; beacon.Publish is reachable cross-realm as
816// beacon.Publish(cross(cur), round, sigHex), already proven by this package's own test harness)
817// that shape of both functions below depends on.
818// ============================================================================
819
820// pendingClaimFor looks up claimant's pending roll, shared by both Settle and
821// SettleWithSignature so the "no pending claim" message and lookup logic exist exactly once.
822func pendingClaimFor(claimant address) *pendingClaim {
823 pcv := pendingClaims.Get(claimant.String())
824 pc, ok := pcv.(*pendingClaim)
825 if !ok || pc == nil {
826 panic("garden: no pending claim for this address; it must Claim first, or this claim has already been settled")
827 }
828 return pc
829}
830
831// Settle computes and permanently mints the result of claimant's pending roll, once the round
832// Claim pinned has actually been published to the beacon by SOMEBODY -- another claimant's own
833// SettleWithSignature, a relay, anyone. It does not publish anything itself; call
834// SettleWithSignature instead if the round is not published yet and the caller has its
835// signature to offer. ANYONE may call this -- the claimant themself, the project's own relayer, a
836// stranger -- because by the time it can succeed at all, nothing is left to decide: the drand
837// signature for the pinned round either exists publicly or it does not, and the parts this mints
838// are a pure function of (that signature, the claimant's own address), fixed the instant the
839// round is published, not the instant Settle happens to be called. Simulating this call before
840// calling it for real previews a fact, not a choice -- exactly the property MINT.md section 10.5
841// names as the whole fix: "reading the result afterward is a free query, same as any other realm
842// read."
843//
844// Refuses coins for the same reason every other non-paying function here does: there is no
845// banker to return them, and this is a free, permissionless computation, never a payment.
846func Settle(cur realm, claimant address) {
847 if !unsafe.OriginSend().IsZero() {
848 panic("garden: settle accepts no coins; it is a free, permissionless computation, not a payment")
849 }
850 if !claimant.IsValid() {
851 panic("garden: settle requires a valid claimant address")
852 }
853
854 pc := pendingClaimFor(claimant)
855
856 if !beacon.Published(pc.round) {
857 panic("garden: drand round " + strconv.FormatInt(pc.round, 10) + " has not been relayed to the beacon yet; call Settle again once it has, or call SettleWithSignature with the round's drand signature (fetched from drand's own public API) to relay it yourself in this same transaction -- relaying is permissionless, and so is settling")
858 }
859
860 settleCore(claimant, pc)
861}
862
863// SettleWithSignature is Settle, plus: if claimant's pinned round has not been published yet,
864// this function publishes it ITSELF first, in this same transaction, using sigHex -- the round's
865// drand signature, which the caller's own app fetches read-only from drand's public API
866// (https://api.drand.sh/<chain>/public/<round> or any mirror) and passes straight through
867// unmodified. This is the function a player's app should call once it has fetched a signature;
868// Settle (above) remains for a caller with no signature to offer, or that already knows (or does
869// not care) that somebody else has published the round.
870//
871// SAFE EITHER WAY THE ROUND TURNS OUT TO BE, which is the one property this function exists to
872// guarantee and the live chain fact its shape is built around (see the package doc): beacon/v2's
873// own Publish PANICS, not no-ops, when the round is already published, so this function checks
874// beacon.Published first and only calls Publish if that check says no. If the round was published
875// by someone else (another claimant's own SettleWithSignature, a relay, anyone) between the
876// moment the caller fetched sigHex and the moment this transaction lands, sigHex is simply never
877// used -- this function still succeeds, exactly as Settle would have. A bad sigHex, by contrast,
878// is a real failure: beacon.Publish rejects it with its own "beacon: invalid signature" panic,
879// which aborts this whole call (and the claim stays pending, to be retried with a correct
880// signature or left for Settle once someone else relays it).
881//
882// Refuses coins for the same reason Settle does and every non-paying function here does: there
883// is no banker to return them, and this is a free, permissionless computation -- the only payment
884// anywhere in this call is the ordinary GNOT gas the caller's own transaction spends, to the
885// network, never to this project (MINT.md section 14).
886func SettleWithSignature(cur realm, claimant address, sigHex string) {
887 if !unsafe.OriginSend().IsZero() {
888 panic("garden: settle accepts no coins; it is a free, permissionless computation, not a payment")
889 }
890 if !claimant.IsValid() {
891 panic("garden: settle requires a valid claimant address")
892 }
893
894 pc := pendingClaimFor(claimant)
895
896 if !beacon.Published(pc.round) {
897 // Permissionless and carries no caller-identity check (verified against the live
898 // deployed beacon/v2 source -- see the package doc): this realm relaying on a player's
899 // behalf is exactly as valid as the player relaying directly, or anyone else doing so.
900 beacon.Publish(cross(cur), pc.round, sigHex)
901 }
902
903 settleCore(claimant, pc)
904}
905
906// settleCore is Settle's and SettleWithSignature's shared tail: the draw, the mint, and every
907// write that follows, once the caller above has already confirmed the pinned round is published.
908// Takes no `cur realm` because it makes no cross-realm call of its own -- beacon.Randomness and
909// beacon.Published below are plain reads, not crossing functions, exactly as they always were
910// before this change.
911func settleCore(claimant address, pc *pendingClaim) {
912 randomnessHex := beacon.Randomness(pc.round)
913
914 if mintedCount < settledCount+1 {
915 // Unreachable: Claim never lets mintedCount fall behind settledCount+1 for a claim that
916 // is about to settle, because mintedCount is incremented before any pendingClaim this
917 // function could ever read exists. Named so this function is correct on its own terms,
918 // the same discipline v4's Buy uses for its own unreachable checks.
919 panic("garden: batch accounting is inconsistent; refusing to mint")
920 }
921
922 // THE NAMED-EDITION PICK. seed % batchCap, against the COMPILE-TIME CONSTANT batchCap, never
923 // against a shrinking count of what remains -- see the package doc's "flaw found and fixed"
924 // section for why that distinction is the whole safety property here. namedPick is fixed the
925 // instant randomnessHex exists (i.e. the instant the pinned round is signed by drand, which
926 // nobody controls), and WHEN Settle happens to be called after that changes nothing about it.
927 namedPick := seedFor(randomnessHex, claimant, "slot") % uint64(batchCap)
928
929 // The ordinary draw is always computed, unconditionally, whether or not namedPick lands in
930 // the named range -- so a claimant whose pick collides with an edition someone else already
931 // settled first has a ready, equally valid fallback rather than needing a second derivation.
932 eyesSeed := seedFor(randomnessHex, claimant, "eyes")
933 noseSeed := seedFor(randomnessHex, claimant, "nose")
934 hatCoatSeed := seedFor(randomnessHex, claimant, "hatcoat")
935 drawnEyes := int64(drawWeighted(eyesWeights[:], 100, eyesSeed))
936 drawnNose := int64(drawWeighted(noseWeights[:], 100, noseSeed))
937 drawnHat := hatOptions[hatCoatSeed%uint64(len(hatOptions))]
938 drawnCoat := coatOptions[(hatCoatSeed/uint64(len(hatOptions)))%uint64(len(coatOptions))]
939
940 character := ""
941 eyes, nose := drawnEyes, drawnNose
942 hat, coat := drawnHat, drawnCoat
943 if namedPick < uint64(numNamedEditions) {
944 candidate := namedEditionOrder[namedPick]
945 if !characterOwner.Has(candidate) {
946 // Whichever Settle call lands on chain first among possibly several colliding
947 // claimants gets this edition; a later one finds characterOwner already holds it and
948 // falls through to its own ordinary draw instead. This is a transaction-ordering
949 // race, not a strategy any claimant can win by choosing WHEN to settle their OWN
950 // claim -- their own pick is already fixed and cannot change -- see the package doc's
951 // "a real flaw found and fixed" section for why claimedAt-based tie-breaking was
952 // considered and rejected, and for the precise property this guarantees instead.
953 character = candidate
954 eyes, nose = -1, -1
955 hat, coat = "", ""
956 }
957 }
958
959 settledCount++
960
961 id := nextID.Next()
962 tid := grc721.TokenID(id.String())
963 if err := ledger.Mint(claimant, tid); err != nil {
964 panic(err)
965 }
966
967 now := time.Now().Unix()
968 gnomes.Set(tid.String(), &gnome{
969 hat: hat,
970 coat: coat,
971 eyes: eyes,
972 nose: nose,
973 character: character,
974 level: 1,
975 adoptedAt: now,
976 belly: meterMax,
977 bellyAt: now,
978 cheer: meterMax,
979 cheerAt: now,
980 lastActionAt: now,
981 lastWokenAt: now,
982 acquiredAt: now,
983 })
984
985 addOwnerToken(claimant, tid.String())
986 if character != "" {
987 characterOwner.Set(character, tid.String())
988 } else {
989 eyesCounts.Set(strconv.FormatInt(eyes, 10), eyesCount(eyes)+1)
990 noseCounts.Set(strconv.FormatInt(nose, 10), noseCount(nose)+1)
991 hatCounts.Set(hat, hatCount(hat)+1)
992 coatCounts.Set(coat, coatCount(coat)+1)
993 }
994
995 grantStartingPurseIfNew(claimant)
996
997 // Auto-wake only if this claimant currently has nothing awake at all (never settled or
998 // bought a gnome before, or sold their only one). Never otherwise: a claimant who already
999 // has an awake gnome gets this one asleep in their stash, exactly as a purchase would (Buy's
1000 // own comment explains why switching must always be a deliberate Wake, never a side effect).
1001 if !activeToken.Has(claimant.String()) {
1002 activeToken.Set(claimant.String(), tid.String())
1003 }
1004
1005 pendingClaims.Remove(claimant.String())
1006
1007 chain.Emit(
1008 "Settle",
1009 "claimant", claimant.String(),
1010 "tokenId", tid.String(),
1011 "round", strconv.FormatInt(pc.round, 10),
1012 "character", character,
1013 "eyes", strconv.FormatInt(eyes, 10),
1014 "nose", strconv.FormatInt(nose, 10),
1015 "hat", hat,
1016 "coat", coat,
1017 )
1018}
1019
1020// fnv64a is a plain, non-cryptographic 64-bit FNV-1a hash. IT IS NOT THE SOURCE OF
1021// UNPREDICTABILITY HERE -- drand's signature for a not-yet-existent round is, exactly as MINT.md
1022// section 10.5 designs. This exists only to DECORRELATE independent draws (two different
1023// claimants sharing a pinned round, or one claimant's own eyes-draw from their own nose-draw)
1024// from a single public drand value, the same category of use math/rand's PCG(0,0) would be fine
1025// for once combined with a secure external seed -- see MINT.md section 10.5's own list of unsafe
1026// constructions for why math/rand ALONE is not used, and note this is never alone: every call
1027// site below mixes in beacon.Randomness(pc.round), which is the only thing doing real work.
1028func fnv64a(s string) uint64 {
1029 const offset64 = uint64(14695981039346656037)
1030 const prime64 = uint64(1099511628211)
1031 h := offset64
1032 for i := 0; i < len(s); i++ {
1033 h ^= uint64(s[i])
1034 h *= prime64
1035 }
1036 return h
1037}
1038
1039// seedFor derives one independent-looking 64-bit value from the published drand randomness, the
1040// claimant's own address, and a purpose string -- so a single drand round can safely drive
1041// several unrelated draws (which named edition, which eyes, which nose, which hat/coat) for
1042// several different simultaneous claimants without any of them correlating with each other.
1043func seedFor(randomnessHex string, claimant address, purpose string) uint64 {
1044 return fnv64a(randomnessHex + "|" + claimant.String() + "|" + purpose)
1045}
1046
1047// drawWeighted picks an index into weights (which TestWeightTablesSumToOneHundred pins as
1048// summing to totalWeight) using seed as the source of randomness -- a plain cumulative-sum
1049// bucket walk, MINT.md section 10.5's own recommendation for what Settle should do ("a simple,
1050// bounded index... easy to audit"), applied to a weight table instead of a published array,
1051// which is the same shape of arithmetic.
1052func drawWeighted(weights []int64, totalWeight int64, seed uint64) int {
1053 if totalWeight <= 0 {
1054 panic("garden: drawWeighted: totalWeight must be positive")
1055 }
1056 x := int64(seed % uint64(totalWeight))
1057 var cum int64
1058 for i, w := range weights {
1059 cum += w
1060 if x < cum {
1061 return i
1062 }
1063 }
1064 // Unreachable if weights sums to exactly totalWeight, which the test above pins. Returning
1065 // the last index rather than panicking keeps Settle from ever aborting a claimant's one and
1066 // only roll over an arithmetic slip elsewhere in this file.
1067 return len(weights) - 1
1068}
1069
1070// ============================================================================
1071// Wake: switching which gnome is awake. MINT.md section 2a.
1072// ============================================================================
1073
1074// Wake makes tokenID the caller's awake gnome. Free, and limited to once per UTC day -- the same
1075// day-boundary discipline SaveDay's own dailyCareDay already uses, chosen over a GNOT fee for the
1076// reason MINT.md section 2a gives: "a cost that cannot be bought, only lived".
1077func Wake(cur realm, tokenID string) {
1078 if !unsafe.OriginSend().IsZero() {
1079 panic("garden: wake accepts no coins; switching which gnome is awake is free, once per UTC day")
1080 }
1081
1082 if tokenID == genesisTokenID {
1083 panic("garden: the genesis gnome belongs to nobody and can never be woken")
1084 }
1085
1086 caller := cur.Previous().Address()
1087 if !caller.IsValid() {
1088 panic("garden: wake requires a valid caller address")
1089 }
1090
1091 owner, ok := ownerOfToken(tokenID)
1092 if !ok || owner != caller {
1093 panic("garden: you do not own that gnome")
1094 }
1095
1096 if active, hasActive := activeToken.Get(caller.String()).(string); hasActive && active == tokenID {
1097 panic("garden: that gnome is already awake")
1098 }
1099
1100 now := time.Now().Unix()
1101 today := dayNumber(now)
1102 if last, isInt := lastWakeDay.Get(caller.String()).(int64); isInt && last == today {
1103 panic("garden: switching which gnome is awake is limited to once per UTC day; try again tomorrow")
1104 }
1105
1106 // g must exist: ownerOfToken above already confirmed tokenID is a real, owned token.
1107 // Recording lastWokenAt HERE, on the token actually being woken, is the other half of the
1108 // fix for ATTACK-REVIEW.md goal 7 -- see the gnome struct's own comment on the field. The
1109 // token being put to sleep (whichever was active(caller) a moment ago, if any) needs no
1110 // symmetrical write: its own lastActionAt/caredDayCount are already frozen facts the instant
1111 // it stops being the active one (SaveDay can only ever act on activeGnomeForAddr's result),
1112 // and the next time anything reactivates IT, that reactivation (another Wake, a later Buy by
1113 // a new owner, etc.) will set its lastWokenAt correctly at that time, exactly as this call is
1114 // doing now.
1115 g, ok := gnomeByTokenID(tokenID)
1116 if !ok {
1117 panic("garden: that gnome does not exist")
1118 }
1119 g.lastWokenAt = now
1120 gnomes.Set(tokenID, g)
1121
1122 activeToken.Set(caller.String(), tokenID)
1123 lastWakeDay.Set(caller.String(), today)
1124
1125 chain.Emit("Wake", "owner", caller.String(), "tokenId", tokenID)
1126}
1127
1128// ============================================================================
1129// SaveDay: v4's shape, exactly, resolved through the caller's ACTIVE gnome rather than "the"
1130// gnome for that address -- because after mint one/hold many/play one, there no longer is one.
1131// A sleeping gnome is simply never found here: activeGnomeForAddr only ever returns the awake
1132// token, so care applied to "my gnome" always means the one gnome actually allowed to earn
1133// anything (MINT.md section 2a: "a sleeping gnome earns zero -- no cared days, no streak, no
1134// level, no XP").
1135// ============================================================================
1136
1137type saveAction struct {
1138 kind string
1139 at int64
1140 plot int
1141}
1142
1143func SaveDay(cur realm, actionsRaw string) {
1144 if !unsafe.OriginSend().IsZero() {
1145 panic("garden: save day accepts no coins; only Buy takes payment, and nothing else here can return them")
1146 }
1147
1148 caller := cur.Previous().Address()
1149 if !caller.IsValid() {
1150 panic("garden: save day requires a valid caller address")
1151 }
1152
1153 tid, g, ok := activeGnomeForAddr(caller)
1154 if !ok {
1155 panic("garden: no gnome is awake for this address; claim or buy one, or Wake one of your own")
1156 }
1157
1158 now := time.Now().Unix()
1159 actions := parseActions(actionsRaw)
1160
1161 if len(actions) == 0 {
1162 chain.Emit("SaveDay", "owner", caller.String(), "tokenId", tid, "actions", "0", "xpApplied", "0")
1163 return
1164 }
1165
1166 earliestAllowed := now - maxBackfillHours*hourSeconds
1167
1168 // awakeSince: the moment THIS token last became the active one (set by settleCore, Buy, and
1169 // Wake -- see the gnome struct's own comment). ATTACK-REVIEW.md goal 7: without this, a
1170 // token's own earliestAllowed was bounded only by its own lastActionAt and the 48-hour
1171 // horizon, with nothing checking that the backfilled window actually fell inside a period
1172 // this specific token was awake. A token that just became active (whether by a brand-new
1173 // Claim/Settle, a Buy, or an explicit Wake switching away from a sibling in the same stable)
1174 // cannot backfill anything from before that moment -- it was asleep then, and MINT.md /
1175 // DECISIONS.md are both explicit that a sleeping gnome earns zero, full stop, not "zero
1176 // unless a sibling token happens to cover the gap."
1177 awakeSince := g.lastWokenAt
1178 if awakeSince > earliestAllowed {
1179 earliestAllowed = awakeSince
1180 }
1181
1182 firstDay := dayNumber(actions[0].at)
1183 prevAt := g.lastActionAt
1184 if prevAt < earliestAllowed {
1185 prevAt = earliestAllowed
1186 }
1187 for _, a := range actions {
1188 if a.at <= earliestAllowed {
1189 if a.at <= awakeSince {
1190 panic("garden: this action predates the last time this gnome was woken; a sleeping gnome earns nothing for the time it was asleep")
1191 }
1192 panic("garden: this action is more than " + strconv.FormatInt(maxBackfillHours, 10) + " hours old; care can only be backfilled for the last " + strconv.FormatInt(maxBackfillHours, 10) + " hours, not longer absences")
1193 }
1194 if a.at <= prevAt {
1195 panic("garden: action timestamp must be strictly after the last saved action")
1196 }
1197 if a.at > now {
1198 panic("garden: action timestamp cannot be in the future")
1199 }
1200 if dayNumber(a.at) != firstDay {
1201 panic("garden: every action in one SaveDay call must fall on the same UTC day")
1202 }
1203 prevAt = a.at
1204 }
1205
1206 work := *g
1207 acorns := balanceOf(caller)
1208 spentThisCall := false
1209
1210 gateWaived := acorns < acornReserve && !anyPlotPlanted(g)
1211 rawCareXp := int64(0)
1212 tinkeredThisCall := false
1213
1214 for _, a := range actions {
1215 switch a.kind {
1216 case actionPat:
1217 if !canPat(work.lastPatAt, a.at, patCooldownHours) {
1218 panic("garden: pat must be at least " + strconv.FormatInt(patCooldownHours, 10) + " hours after the last one")
1219 }
1220 work.lastPatAt = a.at
1221 work.lastHappyEventAt = a.at
1222 work.cheer = clampMeter(meterNow(work.cheer, work.cheerAt, a.at, cheerDropEveryHours) + patCheer)
1223 work.cheerAt = a.at
1224 rawCareXp += xpPat
1225
1226 case actionFeed:
1227 if acorns < feedCost {
1228 panic("garden: not enough acorns to feed")
1229 }
1230 acorns -= feedCost
1231 spentThisCall = true
1232 work.belly = clampMeter(meterNow(work.belly, work.bellyAt, a.at, bellyDropEveryHours) + feedBelly)
1233 work.bellyAt = a.at
1234 work.lastHappyEventAt = a.at
1235 rawCareXp += xpFeed
1236
1237 case actionPlant:
1238 waived := gateWaived && acorns < acornReserve
1239 if !waived && meterNow(work.belly, work.bellyAt, a.at, bellyDropEveryHours) <= hungryAtOrBelow {
1240 panic("garden: too hungry to garden; feed first")
1241 }
1242 p := &work.plots[a.plot]
1243 if p.plantedAt != 0 {
1244 panic("garden: that plot is already planted")
1245 }
1246 if acorns < plantCost {
1247 panic("garden: not enough acorns to plant")
1248 }
1249 acorns -= plantCost
1250 spentThisCall = true
1251 p.plantedAt = a.at
1252 p.readyAt = a.at + growHours*hourSeconds
1253
1254 case actionHarvest:
1255 p := &work.plots[a.plot]
1256 if p.plantedAt == 0 {
1257 panic("garden: that plot has nothing to harvest")
1258 }
1259 if a.at < p.readyAt {
1260 panic("garden: that plot is not ready to harvest yet")
1261 }
1262 p.plantedAt = 0
1263 p.readyAt = 0
1264 acorns += harvestPerPlot
1265 work.xpGarden += xpHarvestPerPlot
1266 work.harvestCount++
1267 rawCareXp += xpHarvestPerPlot
1268
1269 case actionTinker:
1270 if tinkeredThisCall {
1271 panic("garden: only one tinker task per day")
1272 }
1273 if work.lastTinkerAt != 0 && dayNumber(work.lastTinkerAt) == dayNumber(a.at) {
1274 panic("garden: already did a tinker task today")
1275 }
1276 if acorns < tinkerCost {
1277 panic("garden: not enough acorns to tinker")
1278 }
1279 acorns -= tinkerCost
1280 spentThisCall = true
1281 work.xpTinker += xpTinkerTask
1282 work.lastTinkerAt = a.at
1283 tinkeredThisCall = true
1284 rawCareXp += xpTinkerTask
1285
1286 default:
1287 panic("garden: unknown action kind")
1288 }
1289 }
1290
1291 if spentThisCall && acorns < acornReserve && !anyPlotPlanted(&work) {
1292 panic("garden: that would leave fewer than " + strconv.FormatInt(acornReserve, 10) + " acorns with nothing planted, and a gnome with no acorns and no garden can never earn another; plant first, or keep " + strconv.FormatInt(acornReserve, 10) + " back")
1293 }
1294
1295 latestActionAt := actions[len(actions)-1].at
1296 bellyAtEnd := meterNow(work.belly, work.bellyAt, latestActionAt, bellyDropEveryHours)
1297 mood := moodNow(bellyAtEnd, work.lastPatAt, work.lastHappyEventAt, work.adoptedAt, latestActionAt, hungryAtOrBelow)
1298
1299 adjustedXp := rawCareXp
1300 switch mood {
1301 case moodHappy:
1302 adjustedXp = adjustedXp * (100 + happyBonusPct) / 100
1303 case moodSulky:
1304 adjustedXp = adjustedXp * (100 - sulkyPenaltyPct) / 100
1305 }
1306
1307 if work.dailyCareDay != firstDay {
1308 work.dailyCareDay = firstDay
1309 work.dailyCareXp = 0
1310 }
1311 remainingToday := dailyCareCap - work.dailyCareXp
1312 if remainingToday < 0 {
1313 remainingToday = 0
1314 }
1315 if adjustedXp > remainingToday {
1316 adjustedXp = remainingToday
1317 }
1318 work.dailyCareXp += adjustedXp
1319
1320 work.totalXp += adjustedXp
1321 work.level = levelFromXp(work.totalXp)
1322
1323 // At most ONE of this owner's tokens may ever bank a given UTC day as cared-for -- see
1324 // lastCaredDayForOwner's own comment. Without this, Wake (once per day, but still once)
1325 // lets an owner genuinely care for A earlier today, switch to B, and genuinely care for B
1326 // too, banking the SAME calendar day on both from nothing but real, non-backfilled
1327 // timestamps -- the live-switch half of ATTACK-REVIEW.md goal 7 that lastWokenAt's backfill
1328 // floor does not by itself prevent. XP, acorns, feeding, planting and harvesting are
1329 // deliberately NOT gated here: those are this token's own resource mechanics (already
1330 // bounded per-gnome by dailyCareCap) and giving B real care today is still real care: only
1331 // the CARED-DAY LEDGER -- the thing lockCaredDays and the public streak actually count -- is
1332 // restricted to one credit per owner per day.
1333 if lastOwnerDay, ok := lastCaredDayForOwner.Get(caller.String()).(int64); !ok || lastOwnerDay != firstDay {
1334 if recordCaredDay(&work, firstDay) {
1335 lastCaredDayForOwner.Set(caller.String(), firstDay)
1336 }
1337 }
1338 work.lastActionAt = latestActionAt
1339
1340 setBalance(caller, acorns)
1341 *g = work
1342 gnomes.Set(tid, g)
1343
1344 chain.Emit(
1345 "SaveDay",
1346 "owner", caller.String(),
1347 "tokenId", tid,
1348 "actions", strconv.Itoa(len(actions)),
1349 "xpApplied", strconv.FormatInt(adjustedXp, 10),
1350 "mood", mood,
1351 )
1352}
1353
1354// ============================================================================
1355// ChooseType: v4's shape, resolved through the active gnome.
1356// ============================================================================
1357
1358func ChooseType(cur realm, kind string) {
1359 if !unsafe.OriginSend().IsZero() {
1360 panic("garden: choose type accepts no coins; only Buy takes payment, and nothing else here can return them")
1361 }
1362
1363 caller := cur.Previous().Address()
1364 if !caller.IsValid() {
1365 panic("garden: choose type requires a valid caller address")
1366 }
1367
1368 tid, g, ok := activeGnomeForAddr(caller)
1369 if !ok {
1370 panic("garden: no gnome is awake for this address")
1371 }
1372
1373 if g.gnomeType != "" {
1374 panic("garden: this gnome's calling is already chosen and cannot be changed")
1375 }
1376
1377 level := levelFromXp(g.totalXp)
1378 if level < typeAtLevel {
1379 panic("garden: a calling can only be chosen at level " + strconv.FormatInt(typeAtLevel, 10) + " or above")
1380 }
1381
1382 if kind != typeGardener && kind != typeExplorer && kind != typeTinkerer {
1383 panic("garden: a calling is one of \"" + typeGardener + "\", \"" + typeExplorer + "\" or \"" + typeTinkerer + "\"")
1384 }
1385
1386 work := *g
1387 work.gnomeType = kind
1388 work.level = level
1389 *g = work
1390 gnomes.Set(tid, g)
1391
1392 chain.Emit("ChooseType", "owner", caller.String(), "tokenId", tid, "type", kind)
1393}
1394
1395// ============================================================================
1396// SALE: List/Cancel/Buy. v4's shape, DESIGN.md section 9's reasoning unchanged, minus the
1397// one-gnome-per-address refusal in Buy (MINT.md section 2a: removed from Buy, never from Claim)
1398// and plus a tokenID parameter on List/Cancel, because an owner may now have more than one
1399// gnome to choose among, and plus the lockCaredDays check on the three named one-of-ones.
1400// ============================================================================
1401
1402// List offers tokenID, which the caller must own, for sale. The lock: a named one-of-one
1403// (character != "") cannot be listed until it has lockCaredDays cared days; an ordinary gnome
1404// has no such restriction, trading from the moment it is minted (MINT.md section 12/13).
1405func List(cur realm, tokenID string, priceUgnot int64, buyer address, days int64) {
1406 if !unsafe.OriginSend().IsZero() {
1407 panic("garden: list accepts no coins; only Buy takes payment")
1408 }
1409
1410 if tokenID == genesisTokenID {
1411 panic("garden: the genesis gnome belongs to nobody and can never be listed")
1412 }
1413
1414 caller := cur.Previous().Address()
1415 if !caller.IsValid() {
1416 panic("garden: list requires a valid caller address")
1417 }
1418
1419 owner, ok := ownerOfToken(tokenID)
1420 if !ok || owner != caller {
1421 panic("garden: you do not own that gnome")
1422 }
1423
1424 gv := gnomes.Get(tokenID)
1425 g, isGnome := gv.(*gnome)
1426 if !isGnome || g == nil {
1427 panic("garden: that tokenID does not exist")
1428 }
1429
1430 if g.character != "" && g.caredDayCount < lockCaredDays {
1431 panic("garden: " + g.character + " is one of the three named one-of-ones and cannot be listed until it has " +
1432 strconv.FormatInt(lockCaredDays, 10) + " cared days; it has " + strconv.FormatInt(g.caredDayCount, 10))
1433 }
1434
1435 if priceUgnot < 0 {
1436 panic("garden: a price cannot be negative")
1437 }
1438 if priceUgnot > maxPriceUgnot {
1439 panic("garden: price exceeds the maximum of " + strconv.FormatInt(maxPriceUgnot, 10) + " ugnot")
1440 }
1441 if days < 1 || days > maxListingDays {
1442 panic("garden: a listing lasts between 1 and " + strconv.FormatInt(maxListingDays, 10) + " days")
1443 }
1444
1445 buyer = address(lowerASCII(string(buyer)))
1446 if buyer != address("") {
1447 if !buyer.IsValid() {
1448 panic("garden: reserved buyer is not a valid address; pass an empty address to let anyone buy")
1449 }
1450 if buyer == caller {
1451 panic("garden: you cannot reserve your own gnome for yourself")
1452 }
1453 }
1454
1455 now := time.Now().Unix()
1456 listings.Set(tokenID, &listing{
1457 seller: caller,
1458 priceUgnot: priceUgnot,
1459 buyer: buyer,
1460 listedAt: now,
1461 expiresAt: now + days*daySeconds,
1462 })
1463
1464 feeUgnot := feeFor(priceUgnot)
1465 chain.Emit(
1466 "List",
1467 "seller", caller.String(),
1468 "tokenId", tokenID,
1469 "priceUgnot", strconv.FormatInt(priceUgnot, 10),
1470 "feeUgnot", strconv.FormatInt(feeUgnot, 10),
1471 "totalUgnot", strconv.FormatInt(priceUgnot+feeUgnot, 10),
1472 "buyer", buyer.String(),
1473 "expiresAt", strconv.FormatInt(now+days*daySeconds, 10),
1474 )
1475}
1476
1477// Cancel withdraws the caller's own listing on tokenID. Only the seller who created it may.
1478func Cancel(cur realm, tokenID string) {
1479 if !unsafe.OriginSend().IsZero() {
1480 panic("garden: cancel accepts no coins; only Buy takes payment")
1481 }
1482
1483 caller := cur.Previous().Address()
1484 if !caller.IsValid() {
1485 panic("garden: cancel requires a valid caller address")
1486 }
1487
1488 l, ok := listingFor(tokenID)
1489 if !ok {
1490 panic("garden: that gnome is not for sale")
1491 }
1492 if l.seller != caller {
1493 panic("garden: only the seller can cancel their own listing")
1494 }
1495
1496 listings.Remove(tokenID)
1497 chain.Emit("Cancel", "seller", caller.String(), "tokenId", tokenID)
1498}
1499
1500// Buy purchases the listed gnome tokenID. v4's validate-then-commit shape, unchanged, MINUS the
1501// one-gnome-per-address refusal: MINT.md section 2a removes the hold cap from Buy specifically,
1502// and only from Buy -- Claim's hasRolled gate is the cap that still matters, and this function
1503// never touches it.
1504func Buy(cur realm, tokenID string) {
1505 if !cur.Previous().IsUserCall() {
1506 panic("garden: buy must be called directly by a wallet, not through another realm; the coins attached to a transaction belong to the realm the wallet called")
1507 }
1508 buyer := cur.Previous().Address()
1509 if !buyer.IsValid() {
1510 panic("garden: buy requires a valid caller address")
1511 }
1512
1513 if tokenID == genesisTokenID {
1514 panic("garden: the genesis gnome belongs to nobody and can never be bought")
1515 }
1516
1517 l, ok := listingFor(tokenID)
1518 if !ok {
1519 panic("garden: that gnome is not for sale")
1520 }
1521
1522 now := time.Now().Unix()
1523 if now > l.expiresAt {
1524 panic("garden: that listing has expired; the seller must list again")
1525 }
1526 if l.seller == buyer {
1527 panic("garden: you cannot buy your own gnome")
1528 }
1529 if l.buyer != address("") && l.buyer != buyer {
1530 panic("garden: that gnome is reserved for a different buyer")
1531 }
1532
1533 tid := grc721.TokenID(tokenID)
1534 owner, err := token.OwnerOf(tid)
1535 if err != nil || owner != l.seller {
1536 panic("garden: that listing is stale; the seller no longer owns that gnome")
1537 }
1538 gv := gnomes.Get(tokenID)
1539 g, isGnome := gv.(*gnome)
1540 if !isGnome || g == nil {
1541 panic("garden: that listing names a gnome this realm does not hold")
1542 }
1543
1544 feeUgnot := feeFor(l.priceUgnot)
1545 total := l.priceUgnot + feeUgnot
1546 send := unsafe.OriginSend()
1547 if !exactUgnot(send, total) {
1548 panic("garden: buy requires exactly " + strconv.FormatInt(total, 10) + ugnotDenom +
1549 " attached (" + strconv.FormatInt(l.priceUgnot, 10) + " price + " +
1550 strconv.FormatInt(feeUgnot, 10) + " fee); got " + coinsString(send))
1551 }
1552
1553 // ---- commit. Nothing below this line can fail on chain. ----
1554
1555 transferGate = tokenID
1556 if err := ledger.TransferFrom(l.seller, l.seller, buyer, tid); err != nil {
1557 panic(err)
1558 }
1559 transferGate = ""
1560
1561 removeOwnerToken(l.seller, tokenID)
1562 addOwnerToken(buyer, tokenID)
1563 listings.Remove(tokenID)
1564
1565 // Active-token bookkeeping (mint one, hold many, play one). If this was the seller's awake
1566 // gnome, they fall asleep entirely rather than another of their gnomes quietly becoming
1567 // active -- switching is always a deliberate Wake, never a side effect of a sale. If the
1568 // buyer currently has nothing awake at all, this purchase becomes their active gnome
1569 // automatically, so a first-time buyer is never left with nothing playable.
1570 if av, isActive := activeToken.Get(l.seller.String()).(string); isActive && av == tokenID {
1571 activeToken.Remove(l.seller.String())
1572 }
1573 if !activeToken.Has(buyer.String()) {
1574 activeToken.Set(buyer.String(), tokenID)
1575 }
1576
1577 settled := int64(0)
1578 for i := 0; i < numPlots; i++ {
1579 p := &g.plots[i]
1580 if p.plantedAt == 0 {
1581 continue
1582 }
1583 if now >= p.readyAt {
1584 settled += harvestPerPlot
1585 } else {
1586 settled += plantCost
1587 }
1588 p.plantedAt = 0
1589 p.readyAt = 0
1590 }
1591 if settled > 0 {
1592 setBalance(l.seller, balanceOf(l.seller)+settled)
1593 }
1594
1595 g.acquiredAt = now
1596 g.saleCount++
1597 g.lastActionAt = now
1598 // lastWokenAt resets here too, same as lastActionAt, whether or not this purchase becomes
1599 // the buyer's active gnome below -- if it does not (buyer already has something awake), this
1600 // value is simply overwritten by the eventual explicit Wake that does activate it. Either
1601 // way, nothing the SELLER (or anyone else) did with this token before the sale can be
1602 // backfilled by the BUYER afterward: SaveDay's floor cannot reach earlier than this moment.
1603 g.lastWokenAt = now
1604 gnomes.Set(tokenID, g)
1605
1606 grantStartingPurseIfNew(buyer)
1607
1608 bank := banker.NewBanker(banker.BankerTypeOriginSend, cur)
1609 me := cur.Address()
1610 if l.priceUgnot > 0 {
1611 bank.SendCoins(me, l.seller, chain.Coins{{Denom: ugnotDenom, Amount: l.priceUgnot}})
1612 }
1613 bank.SendCoins(me, fee.recipient, chain.Coins{{Denom: ugnotDenom, Amount: feeUgnot}})
1614
1615 chain.Emit(
1616 "Sale",
1617 "tokenId", tokenID,
1618 "seller", l.seller.String(),
1619 "buyer", buyer.String(),
1620 "priceUgnot", strconv.FormatInt(l.priceUgnot, 10),
1621 "feeUgnot", strconv.FormatInt(feeUgnot, 10),
1622 "feeRecipient", fee.recipient.String(),
1623 "settledAcorns", strconv.FormatInt(settled, 10),
1624 )
1625}
1626
1627// ============================================================================
1628// Readers
1629// ============================================================================
1630
1631func Listing(tokenID string) (seller address, priceUgnot int64, buyer address, listedAtUnix, expiresAtUnix, feeUgnot, totalUgnot int64, found bool) {
1632 l, ok := listingFor(tokenID)
1633 if !ok {
1634 return address(""), 0, address(""), 0, 0, 0, 0, false
1635 }
1636 fee := feeFor(l.priceUgnot)
1637 return l.seller, l.priceUgnot, l.buyer, l.listedAt, l.expiresAt, fee, l.priceUgnot + fee, true
1638}
1639
1640func TotalListings() int64 {
1641 return int64(listings.Size())
1642}
1643
1644func ListingsPage(offset, limit int) []string {
1645 if offset < 0 {
1646 panic("garden: offset must not be negative")
1647 }
1648 if limit <= 0 {
1649 panic("garden: limit must be positive")
1650 }
1651 if limit > maxListingsPageSize {
1652 limit = maxListingsPageSize
1653 }
1654 tokenIDs := make([]string, 0, limit)
1655 listings.IterateByOffset(offset, limit, func(key string, value any) bool {
1656 tokenIDs = append(tokenIDs, key)
1657 return false
1658 })
1659 return tokenIDs
1660}
1661
1662func QuoteFee(priceUgnot int64) (feeUgnot, totalUgnot int64) {
1663 f := feeFor(priceUgnot)
1664 return f, priceUgnot + f
1665}
1666
1667func FeeRules() string {
1668 return "saleFeeRateBps=" + strconv.FormatInt(fee.rateBps, 10) +
1669 ";saleFeeFloorUgnot=" + strconv.FormatInt(fee.floorUgnot, 10) +
1670 ";saleFeeRecipient=" + fee.recipient.String()
1671}
1672
1673func Provenance(tokenID string) (adoptedAtUnix, acquiredAtUnix, saleCount int64, found bool) {
1674 g, ok := gnomeByTokenID(tokenID)
1675 if !ok {
1676 return 0, 0, 0, false
1677 }
1678 return g.adoptedAt, g.acquiredAt, g.saleCount, true
1679}
1680
1681// GnomeByToken: the basic facts for one token -- hat, coat, eyes, nose, character (empty for an
1682// ordinary gnome), level, calling and the moment it was minted.
1683func GnomeByToken(tokenID string) (hat, coat string, eyes, nose int64, character string, level int64, gnomeType string, adoptedAtUnix int64, found bool) {
1684 g, ok := gnomeByTokenID(tokenID)
1685 if !ok {
1686 return "", "", 0, 0, "", 0, "", 0, false
1687 }
1688 return g.hat, g.coat, g.eyes, g.nose, g.character, g.level, g.gnomeType, g.adoptedAt, true
1689}
1690
1691// ActiveGnome returns the tokenID that is awake for addr right now, or found=false if nothing
1692// is (never claimed or bought a gnome, or sold the only one and has not Woken another since).
1693func ActiveGnome(addr address) (tokenID string, found bool) {
1694 tid, isStr := activeToken.Get(addr.String()).(string)
1695 if !isStr || tid == "" {
1696 return "", false
1697 }
1698 return tid, true
1699}
1700
1701// OwnerTokens returns every tokenID addr currently holds, in no particular order. Unbounded and
1702// unpaginated deliberately: batchCap is 24 and the genesis gnome is permanently unreachable to
1703// any wallet, so no address can ever hold more than batchCap tokens, which is a small, fixed
1704// upper bound this call is always safely within.
1705func OwnerTokens(addr address) []string {
1706 v := ownerTokens.Get(addr.String())
1707 list, ok := v.(*[]string)
1708 if !ok || list == nil {
1709 return []string{}
1710 }
1711 out := make([]string, len(*list))
1712 copy(out, *list)
1713 return out
1714}
1715
1716// TokensPage: the global token enumerator, hollowmere/v1's own pattern (TotalClaimants/
1717// ClaimantsPage) applied here, because v4's own missing-enumerator lesson (DECISIONS.md:780-791,
1718// arena/v1 shipping with no challenge inbox and no way to ever add one) is unavailable to fix
1719// after deploy and twenty lines now is cheaper than "unavailable forever" later.
1720func TokensPage(offset, limit int) []string {
1721 if offset < 0 {
1722 panic("garden: offset must not be negative")
1723 }
1724 if limit <= 0 {
1725 panic("garden: limit must be positive")
1726 }
1727 if limit > maxGnomesPageSize {
1728 limit = maxGnomesPageSize
1729 }
1730 ids := make([]string, 0, limit)
1731 gnomes.IterateByOffset(offset, limit, func(key string, value any) bool {
1732 ids = append(ids, key)
1733 return false
1734 })
1735 return ids
1736}
1737
1738func GardenState(tokenID string) (plantedAtCsv, readyAtCsv string, readyCount int64, found bool) {
1739 g, ok := gnomeByTokenID(tokenID)
1740 if !ok {
1741 return "", "", 0, false
1742 }
1743 now := time.Now().Unix()
1744 planted := make([]string, 0, numPlots)
1745 ready := make([]string, 0, numPlots)
1746 for i := 0; i < numPlots; i++ {
1747 p := g.plots[i]
1748 planted = append(planted, strconv.FormatInt(p.plantedAt, 10))
1749 ready = append(ready, strconv.FormatInt(p.readyAt, 10))
1750 if p.plantedAt != 0 && now >= p.readyAt {
1751 readyCount++
1752 }
1753 }
1754 return strings.Join(planted, ","), strings.Join(ready, ","), readyCount, true
1755}
1756
1757func CareState(tokenID string) (belly, cheer, acorns, totalXp, lastPatAtUnix, lastHappyEventAtUnix, lastActionAtUnix int64, found bool) {
1758 g, ok := gnomeByTokenID(tokenID)
1759 if !ok {
1760 return 0, 0, 0, 0, 0, 0, 0, false
1761 }
1762 owner, hasOwner := ownerOfToken(tokenID)
1763 acornBalance := int64(0)
1764 if hasOwner {
1765 acornBalance = balanceOf(owner)
1766 }
1767 now := time.Now().Unix()
1768 return meterNow(g.belly, g.bellyAt, now, bellyDropEveryHours),
1769 meterNow(g.cheer, g.cheerAt, now, cheerDropEveryHours),
1770 acornBalance, g.totalXp, g.lastPatAt, g.lastHappyEventAt, g.lastActionAt, true
1771}
1772
1773func MeterState(tokenID string) (belly, bellyAtUnix, cheer, cheerAtUnix int64, found bool) {
1774 g, ok := gnomeByTokenID(tokenID)
1775 if !ok {
1776 return 0, 0, 0, 0, false
1777 }
1778 return g.belly, g.bellyAt, g.cheer, g.cheerAt, true
1779}
1780
1781func Progress(tokenID string) (xpGarden, xpTinker, harvestCount, lastTinkerAtUnix, caredDayCount, dailyCareXpSpent, dailyCareDay int64, found bool) {
1782 g, ok := gnomeByTokenID(tokenID)
1783 if !ok {
1784 return 0, 0, 0, 0, 0, 0, 0, false
1785 }
1786 return g.xpGarden, g.xpTinker, g.harvestCount, g.lastTinkerAt,
1787 g.caredDayCount, g.dailyCareXp, g.dailyCareDay, true
1788}
1789
1790func CaredDays(tokenID string) (daysCsv string, found bool) {
1791 g, ok := gnomeByTokenID(tokenID)
1792 if !ok {
1793 return "", false
1794 }
1795 return caredDaysCsv(g), true
1796}
1797
1798func Mood(tokenID string) (mood string, found bool) {
1799 g, ok := gnomeByTokenID(tokenID)
1800 if !ok {
1801 return "", false
1802 }
1803 now := time.Now().Unix()
1804 belly := meterNow(g.belly, g.bellyAt, now, bellyDropEveryHours)
1805 return moodNow(belly, g.lastPatAt, g.lastHappyEventAt, g.adoptedAt, now, hungryAtOrBelow), true
1806}
1807
1808// IsLocked reports whether tokenID is currently refused by List's lockCaredDays check -- true
1809// only for a named one-of-one with fewer than lockCaredDays cared days; false for every ordinary
1810// gnome, always, and false for a named one-of-one once it has enough cared days.
1811func IsLocked(tokenID string) (locked bool, found bool) {
1812 g, ok := gnomeByTokenID(tokenID)
1813 if !ok {
1814 return false, false
1815 }
1816 return g.character != "" && g.caredDayCount < lockCaredDays, true
1817}
1818
1819func CollectionInfo() (name, symbol string) {
1820 return collectionName, collectionSymbol
1821}
1822
1823func TotalSupply() int64 {
1824 return token.TotalSupply()
1825}
1826
1827func BalanceOf(addr address) int64 {
1828 n, err := token.BalanceOf(addr)
1829 if err != nil {
1830 return 0
1831 }
1832 return n
1833}
1834
1835func OwnerOfToken(tokenID string) (owner address, found bool) {
1836 return ownerOfToken(tokenID)
1837}
1838
1839func TotalGnomes() int64 {
1840 return token.TotalSupply()
1841}
1842
1843func Acorns(addr address) (acorns int64, found bool) {
1844 if !balances.Has(addr.String()) {
1845 return 0, false
1846 }
1847 return balanceOf(addr), true
1848}
1849
1850// ---- counted rarity readers (MINT.md section 13): "nine gnomes in the world have this nose"
1851// has to be a chain fact. These four, plus CharacterOwner below, are that fact.
1852
1853func EyesCount(id int64) int64 { return eyesCount(id) }
1854func NoseCount(id int64) int64 { return noseCount(id) }
1855func HatCount(hat string) int64 { return hatCount(hat) }
1856func CoatCount(coat string) int64 { return coatCount(coat) }
1857
1858// CharacterOwner resolves one of the three named one-of-ones, or the genesis gnome under its
1859// own name "genesis", to its tokenID, once minted. (Second-audit-pass nitpick: an earlier
1860// version of this comment said only "one of the three named one-of-ones", which stopped being
1861// true the moment init() registered "genesis" here too -- see init()'s own comment for why that
1862// registration is safe and never touches the three editions' cap.)
1863func CharacterOwner(name string) (tokenID string, found bool) {
1864 v := characterOwner.Get(name)
1865 s, ok := v.(string)
1866 if !ok {
1867 return "", false
1868 }
1869 return s, true
1870}
1871
1872// ---- the mint's own state, read back.
1873
1874func IsAllowlisted(addr address) bool {
1875 return allowlist.Has(addr.String())
1876}
1877
1878func HasRolled(addr address) bool {
1879 return hasRolled.Has(addr.String())
1880}
1881
1882func PendingClaim(addr address) (round, claimedAtUnix int64, found bool) {
1883 v := pendingClaims.Get(addr.String())
1884 pc, ok := v.(*pendingClaim)
1885 if !ok || pc == nil {
1886 return 0, 0, false
1887 }
1888 return pc.round, pc.claimedAt, true
1889}
1890
1891// BatchStatus: mintedCount is reservations made by Claim (gates batchCap); settledCount is
1892// completions made by Settle (always <= mintedCount, the gap being pending, unsettled claims);
1893// namedMinted is how many of the three named one-of-ones have actually been minted so far --
1894// read live off characterOwner rather than a separate counter, so there is only ever one source
1895// of truth for "has this edition been taken".
1896func BatchStatus() (mintedSoFar, settledSoFar, batchCapOut, namedMinted, namedCapOut int64) {
1897 // NAMED RETURNS DELIBERATELY DO NOT SHARE A NAME WITH THE PACKAGE-LEVEL mintedCount/
1898 // settledCount VARS. An earlier version of this function used mintedCount/settledCount as
1899 // its own named returns, which SHADOWED the package vars for the whole function body, so
1900 // `return mintedCount, settledCount, ...` returned the zero-valued local named returns
1901 // instead of the real package state -- this function always reported a fresh batch,
1902 // silently, with no error, which TestBatchClosesAtCapAndRefusesFurtherClaims caught by
1903 // actually driving claims until the realm said the batch was full and finding it never did.
1904 // Found and fixed before audit; recorded here rather than silently corrected, the same
1905 // discipline this file's own package doc uses for the Settle timing flaw.
1906 nm := int64(0)
1907 for _, name := range namedEditionOrder {
1908 if characterOwner.Has(name) {
1909 nm++
1910 }
1911 }
1912 return mintedCount, settledCount, batchCap, nm, numNamedEditions
1913}
1914
1915func GenesisTokenID() string {
1916 return genesisTokenID
1917}
1918
1919func AdminState() (admin address, frozen bool) {
1920 return address(adminAddress), rulesFrozen
1921}
1922
1923func LastWakeDay(addr address) (day int64, found bool) {
1924 v, ok := lastWakeDay.Get(addr.String()).(int64)
1925 return v, ok
1926}
1927
1928// ============================================================================
1929// Render
1930// ============================================================================
1931
1932func Render(path string) string {
1933 if path != "" {
1934 return "garden/v5: unknown path"
1935 }
1936 minted, settled, cap_, namedMinted, namedCap := BatchStatus()
1937 out := "# Gnotchi gnomes -- garden/v5\n\n"
1938 out += "Batch One: " + strconv.FormatInt(minted, 10) + " claimed, " +
1939 strconv.FormatInt(settled, 10) + " settled, of " + strconv.FormatInt(cap_, 10) + " ever.\n\n"
1940 out += "Named one-of-ones minted: " + strconv.FormatInt(namedMinted, 10) + " of " + strconv.FormatInt(namedCap, 10) + ".\n\n"
1941 out += "Total supply (including the genesis gnome): " + strconv.FormatInt(TotalSupply(), 10) + "\n\n"
1942 out += "Free to claim, if your address is on the allowlist. One roll per wallet, ever. " +
1943 "Claim pins a future drand round; Settle, callable by anyone once that round is " +
1944 "published, mints the result.\n"
1945 return out
1946}
1947
1948// ============================================================================
1949// Internal helpers
1950// ============================================================================
1951
1952func gnomeByTokenID(tokenID string) (*gnome, bool) {
1953 v := gnomes.Get(tokenID)
1954 g, ok := v.(*gnome)
1955 if !ok || g == nil {
1956 return nil, false
1957 }
1958 return g, true
1959}
1960
1961// activeGnomeForAddr resolves addr's AWAKE gnome, if any. This is the one and only lookup
1962// SaveDay and ChooseType use -- there is deliberately no "the gnome for this address" any more.
1963func activeGnomeForAddr(addr address) (tid string, g *gnome, found bool) {
1964 tidStr, ok := activeToken.Get(addr.String()).(string)
1965 if !ok || tidStr == "" {
1966 return "", nil, false
1967 }
1968 gg, ok := gnomeByTokenID(tidStr)
1969 if !ok {
1970 return "", nil, false
1971 }
1972 return tidStr, gg, true
1973}
1974
1975func ownerOfToken(tokenID string) (owner address, found bool) {
1976 if !gnomes.Has(tokenID) {
1977 return address(""), false
1978 }
1979 o, err := token.OwnerOf(grc721.TokenID(tokenID))
1980 if err != nil {
1981 return address(""), false
1982 }
1983 return o, true
1984}
1985
1986// addOwnerToken / removeOwnerToken: the hold-many index. ownerTokens stores a *[]string per
1987// owner so repeated appends mutate one allocation rather than replacing the tree entry every
1988// time; removal does a linear scan, which is fine at this realm's permanent, tiny upper bound
1989// (batchCap = 24 tokens that will ever exist, minus the one permanently unreachable genesis
1990// gnome, so no owner's slice is ever longer than 24 entries for the whole life of this realm).
1991func addOwnerToken(addr address, tokenID string) {
1992 v := ownerTokens.Get(addr.String())
1993 list, ok := v.(*[]string)
1994 if !ok || list == nil {
1995 fresh := []string{tokenID}
1996 ownerTokens.Set(addr.String(), &fresh)
1997 return
1998 }
1999 *list = append(*list, tokenID)
2000}
2001
2002func removeOwnerToken(addr address, tokenID string) {
2003 v := ownerTokens.Get(addr.String())
2004 list, ok := v.(*[]string)
2005 if !ok || list == nil {
2006 return
2007 }
2008 out := make([]string, 0, len(*list))
2009 for _, id := range *list {
2010 if id != tokenID {
2011 out = append(out, id)
2012 }
2013 }
2014 *list = out
2015}
2016
2017func balanceOf(addr address) int64 {
2018 v := balances.Get(addr.String())
2019 n, ok := v.(int64)
2020 if !ok {
2021 return 0
2022 }
2023 return n
2024}
2025
2026func setBalance(addr address, v int64) {
2027 if v < 0 {
2028 panic("garden: acorn balance cannot go negative")
2029 }
2030 balances.Set(addr.String(), v)
2031}
2032
2033func grantStartingPurseIfNew(addr address) {
2034 if !balances.Has(addr.String()) {
2035 balances.Set(addr.String(), acornsStart)
2036 }
2037}
2038
2039func feeFor(priceUgnot int64) int64 {
2040 return fee.floorUgnot + priceUgnot*fee.rateBps/10000
2041}
2042
2043func exactUgnot(send chain.Coins, want int64) bool {
2044 if len(send) != 1 {
2045 return false
2046 }
2047 return send[0].Denom == ugnotDenom && send[0].Amount == want
2048}
2049
2050func coinsString(c chain.Coins) string {
2051 if len(c) == 0 {
2052 return "nothing"
2053 }
2054 out := ""
2055 for i, coin := range c {
2056 if i > 0 {
2057 out += ","
2058 }
2059 out += strconv.FormatInt(coin.Amount, 10) + coin.Denom
2060 }
2061 return out
2062}
2063
2064func listingFor(tokenID string) (*listing, bool) {
2065 v := listings.Get(tokenID)
2066 l, ok := v.(*listing)
2067 if !ok || l == nil {
2068 return nil, false
2069 }
2070 if l.priceUgnot < 0 {
2071 return nil, false
2072 }
2073 return l, true
2074}
2075
2076// lowerASCII: v4's own helper, unchanged -- ASCII-only on purpose (see v4's own comment on why
2077// not strings.ToLower).
2078func lowerASCII(s string) string {
2079 b := []byte(s)
2080 for i, c := range b {
2081 if c >= 'A' && c <= 'Z' {
2082 b[i] = c + ('a' - 'A')
2083 }
2084 }
2085 return string(b)
2086}
2087
2088func anyPlotPlanted(g *gnome) bool {
2089 for i := 0; i < numPlots; i++ {
2090 if g.plots[i].plantedAt != 0 {
2091 return true
2092 }
2093 }
2094 return false
2095}
2096
2097func eyesCount(id int64) int64 {
2098 v := eyesCounts.Get(strconv.FormatInt(id, 10))
2099 n, ok := v.(int64)
2100 if !ok {
2101 return 0
2102 }
2103 return n
2104}
2105
2106func noseCount(id int64) int64 {
2107 v := noseCounts.Get(strconv.FormatInt(id, 10))
2108 n, ok := v.(int64)
2109 if !ok {
2110 return 0
2111 }
2112 return n
2113}
2114
2115func hatCount(hat string) int64 {
2116 v := hatCounts.Get(hat)
2117 n, ok := v.(int64)
2118 if !ok {
2119 return 0
2120 }
2121 return n
2122}
2123
2124func coatCount(coat string) int64 {
2125 v := coatCounts.Get(coat)
2126 n, ok := v.(int64)
2127 if !ok {
2128 return 0
2129 }
2130 return n
2131}
2132
2133// ---- parsing, meters, mood, level: v4's own proven implementations, byte-for-byte, with
2134// rule() lookups replaced by this file's fixed constants (see the package doc: no SetRule here).
2135
2136func parseActions(raw string) []saveAction {
2137 if raw == "" {
2138 return nil
2139 }
2140
2141 count := 1
2142 for i := 0; i < len(raw); i++ {
2143 if raw[i] == ';' {
2144 count++
2145 }
2146 }
2147 if count > maxActionsPerCall {
2148 panic("garden: too many actions in one call; the limit is " + strconv.Itoa(maxActionsPerCall))
2149 }
2150
2151 parts := strings.Split(raw, ";")
2152 actions := make([]saveAction, 0, len(parts))
2153 for _, part := range parts {
2154 if part == "" {
2155 panic("garden: empty action entry")
2156 }
2157 fields := strings.Split(part, ":")
2158 switch fields[0] {
2159 case actionPat, actionFeed, actionTinker:
2160 if len(fields) != 2 {
2161 panic("garden: malformed action: " + quoteAction(part))
2162 }
2163 actions = append(actions, saveAction{kind: fields[0], at: parseUnixField(fields[1], part)})
2164 case actionPlant, actionHarvest:
2165 if len(fields) != 3 {
2166 panic("garden: malformed action: " + quoteAction(part))
2167 }
2168 actions = append(actions, saveAction{
2169 kind: fields[0],
2170 plot: parsePlotField(fields[1], part),
2171 at: parseUnixField(fields[2], part),
2172 })
2173 default:
2174 panic("garden: unknown action kind: " + quoteAction(part))
2175 }
2176 }
2177 return actions
2178}
2179
2180func quoteAction(part string) string {
2181 return "\"" + part + "\""
2182}
2183
2184func parseUnixField(s, part string) int64 {
2185 n, err := strconv.ParseInt(s, 10, 64)
2186 if err != nil || n < 0 {
2187 panic("garden: malformed timestamp in action: " + quoteAction(part))
2188 }
2189 return n
2190}
2191
2192func parsePlotField(s, part string) int {
2193 n, err := strconv.Atoi(s)
2194 if err != nil || n < 0 || n >= numPlots {
2195 panic("garden: malformed plot index in action: " + quoteAction(part))
2196 }
2197 return n
2198}
2199
2200func dayNumber(unixSeconds int64) int64 { return unixSeconds / daySeconds }
2201
2202// recordCaredDay reports, via banked, whether it actually incremented g.caredDayCount -- false
2203// on the no-op branch (day == g.lastCaredDay already, e.g. a second SaveDay call the same UTC
2204// day, or a token bought mid-day that the SELLER had already banked that exact day for before
2205// the sale). SaveDay's own caller-scoped gate (lastCaredDayForOwner) depends on this: it must
2206// only spend the owner's one-bank-per-day allowance on a call that actually banked something,
2207// never on a no-op -- a gno-auditor re-audit found that an unconditional advance let a buyer's
2208// SaveDay on a token the seller had already banked that day silently consume the buyer's own
2209// allowance for the rest of that day, denying an otherwise fully legitimate cared day on a
2210// DIFFERENT, genuinely active token later the same day. See the call site in SaveDay.
2211func recordCaredDay(g *gnome, day int64) (banked bool) {
2212 if g.lastCaredDay == 0 {
2213 g.lastCaredDay = day
2214 g.caredMask = 1
2215 g.caredDayCount = 1
2216 return true
2217 }
2218 if day == g.lastCaredDay {
2219 return false
2220 }
2221 shift := uint(day - g.lastCaredDay)
2222 if shift >= caredWindowDays {
2223 g.caredMask = 1
2224 } else {
2225 g.caredMask = (g.caredMask << shift) | 1
2226 }
2227 g.lastCaredDay = day
2228 g.caredDayCount++
2229 return true
2230}
2231
2232func caredDaysCsv(g *gnome) string {
2233 if g.lastCaredDay == 0 {
2234 return ""
2235 }
2236 days := make([]string, 0, caredWindowDays)
2237 for i := uint(0); i < caredWindowDays; i++ {
2238 if g.caredMask&(1<<i) != 0 {
2239 days = append(days, strconv.FormatInt(g.lastCaredDay-int64(i), 10))
2240 }
2241 }
2242 return strings.Join(days, ",")
2243}
2244
2245func meterNow(value, setAt, now, dropEveryHours int64) int64 {
2246 if dropEveryHours <= 0 {
2247 return value
2248 }
2249 elapsedHours := (now - setAt) / hourSeconds
2250 if elapsedHours <= 0 {
2251 return clampMeter(value)
2252 }
2253 drops := elapsedHours / dropEveryHours
2254 return clampMeter(value - drops)
2255}
2256
2257func clampMeter(v int64) int64 {
2258 if v < 0 {
2259 return 0
2260 }
2261 if v > meterMax {
2262 return meterMax
2263 }
2264 return v
2265}
2266
2267func canPat(lastPatAt, now, cooldownHours int64) bool {
2268 if lastPatAt == 0 {
2269 return true
2270 }
2271 return now-lastPatAt >= cooldownHours*hourSeconds
2272}
2273
2274func moodNow(belly, lastPatAt, lastHappyEventAt, adoptedAt, now, hungryThreshold int64) string {
2275 if belly <= hungryThreshold {
2276 return moodHungry
2277 }
2278 if lastHappyEventAt == 0 {
2279 if now-adoptedAt <= happyWindowHours*hourSeconds {
2280 return moodContent
2281 }
2282 return moodSulky
2283 }
2284 if now-lastHappyEventAt <= happyWindowHours*hourSeconds {
2285 return moodHappy
2286 }
2287 if now-lastHappyEventAt >= sulkyAfterHours*hourSeconds {
2288 return moodSulky
2289 }
2290 return moodContent
2291}
2292
2293func xpToNext(level int64) int64 { return levelBase + levelStep*level }
2294
2295func totalXpForLevel(level int64) int64 {
2296 if level <= 1 {
2297 return 0
2298 }
2299 total := int64(0)
2300 for l := int64(1); l < level; l++ {
2301 total += xpToNext(l)
2302 }
2303 return total
2304}
2305
2306func levelFromXp(totalXp int64) int64 {
2307 level := int64(1)
2308 for level < maxLevel {
2309 need := totalXpForLevel(level + 1)
2310 if totalXp < need {
2311 break
2312 }
2313 level++
2314 }
2315 return level
2316}