adstation.gno
38.53 Kb · 1216 lines
1// Package adstation sells the panels of gno.world's ad station by the week, for GNOT, to ads an approver has passed
2// (jae, his terminal, 2026-10-14: "lets make a site for the ad station, maybe ads.gno.world, where you can bid in gnot
3// for your ad to show after approval" … "if somebody comes in and says i want this this this this panel, well, if
4// they pay, they get it right? so make it flexible, market based, interesting").
5//
6// THE UNIT is a panel, by index 0 … Panels()-1; the page maps an index to the station (face, row, column) and offers
7// its colour groups as ready-made selections, so the groups can change without this realm knowing. A bid names any
8// set of panels ("0-8,27-35"), one ad and one week, and carries its GNOT with it.
9//
10// THE WEEK starts Monday 00:00 UTC and is named by that unix time. Bids for a week close a day before it starts;
11// weeks up to AheadWeeks ahead are open. An approver passes or turns down each ad; a turned-down ad's open bids are
12// refunded at once. From the week's start anyone may Settle it: the open bids whose ad is approved are ranked two
13// ways — by price per panel, and by the whole amount — each in turn winning if none of its panels is taken yet, and
14// the way that pays more decides the week (see allocate); the winners' GNOT goes to the ad station's treasury
15// (r/…/adtreasury), everyone else's back to them.
16// Refunds are credited and paid by Withdraw (the owner of the money) or PayOut (anyone, for them).
17//
18// THE ROLES: an owner (two-step hand-off) names the approvers and sets the minimum bid, the panel count of weeks not
19// yet bid on, and a pause on new bids; neither the owner nor an approver can move a bid's GNOT anywhere but back to
20// its bidder or, by settling, to the treasury. The owner and first approver is Jae's cold key.
21//
22// Immutable, not private (gnomod.toml).
23package adstation
24
25import (
26 "chain"
27 "chain/banker"
28 "chain/runtime/unsafe"
29 "sort"
30 "strconv"
31 "strings"
32 "time"
33
34 "gno.land/p/nt/avl/v0"
35 "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/adtreasury"
36)
37
38const (
39 pkgPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/adstation" // must match gnomod.toml
40 coldKey = address("g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6") // Jae's: the first owner and approver
41 GNOT = "ugnot"
42
43 WeekLen = int64(7 * 86400)
44 Monday0 = int64(4 * 86400) // 1970-01-05 00:00 UTC, the first Monday after the epoch
45 CloseAhead = int64(86400) // a week's bidding closes a day before it starts
46 AheadWeeks = int64(8) // weeks open for bidding, counting from the next one
47
48 MaxPanels = 1024 // the most panels a week may have
49 MaxBidsWeek = 200 // bids a week holds (Settle ranks them all in one call); a full week takes a larger bid in place of its smallest
50 MaxBidsBidderWeek = 10 // open bids one wallet may hold in one week
51 MaxOpenBidsAd = 32 // open bids one ad may carry (a turn-down refunds them in one call)
52 MaxPending = 100 // ads waiting for an approver with a bid at stake, at once
53 MaxPendingOwner = 5 // … of them one advertiser's
54 maxRejectMany = 10 // ads one RejectMany turns down: ten with 32 open bids each in full weeks cost ~0.94B gas on a node, fifty ~3.57B, more than a block (review round 2, R2-3)
55 maxTitle = 60
56 maxLink = 200
57 maxReason = 120
58 maxPanelsSpec = 2000
59 listMax = 50
60 defaultPanels = 108 // the ring: 27 columns × 2 rows × 2 faces
61 defaultMinBid = 100_000_000 // 100 GNOT (jae: "100 GNOT (~$7)")
62)
63
64// Ad is one approved-or-not picture with its link and title; immutable once made, so an approval covers exactly it.
65type Ad struct {
66 ID int64
67 Owner address // the advertiser who made it with their first bid
68 Image string // sha256 hex of the picture as the site stores it
69 Link string // https://…
70 Title string
71 State string // "pending", "approved", "rejected"
72 Reason string // why it was turned down
73 By address
74 At int64 // when it was decided
75 Created int64
76 open []int64 // its open bids
77 queued bool // counted in pending: waiting, with a bid at stake
78}
79
80// Offer is one bid: a price for a set of panels in one week, for one ad, its GNOT held here until the week settles.
81type Offer struct {
82 ID int64
83 Week int64 // the week's start
84 Ad int64
85 Bidder address
86 Panels string // canonical: "0-8,27-35"
87 Count int // panels in it
88 Amount int64 // ugnot
89 Placed int64
90 State string // "open", "won", "lost", "refunded" (its ad was turned down), "bumped" (a full week gave its place up)
91 bits []uint64
92}
93
94// Week holds a week's bids and, once settled, its winners.
95type Week struct {
96 Start int64
97 N int // its panel count, fixed at its first bid
98 Settled bool
99 Paid int64 // to the treasury
100 bids []int64
101 amts []int64 // each listed bid's amount, beside bids: a full week finds its smallest bid without reading every bid
102 appr []bool // beside bids: its ad is approved, so a full week never gives its place up (review round 2, R2-1)
103 won []int64
104 per map[address]int // each bidder's open bids in it, until it settles (a bid counts its bidder's without a scan)
105}
106
107var (
108 owner = coldKey
109 nextOwner address
110 approvers avl.Tree // address → true
111 minBid = int64(defaultMinBid)
112 panels = defaultPanels
113 paused bool
114
115 ads avl.Tree // padded id → *Ad
116 bids avl.Tree // padded id → *Offer
117 weeks avl.Tree // padded start → *Week
118 owed avl.Tree // address → int64 ugnot
119 lastAd int64
120 lastBid int64
121 byOwner avl.Tree // advertiser → []int64: their latest ads, oldest first (their own record: json/by/<address>)
122 byBidder avl.Tree // bidder → []int64: their latest bids, oldest first — a bumped or refunded bid leaves its week, not this
123 pending int // ads waiting for an approver with an open bid (queue, unqueue)
124 pendingBy avl.Tree // advertiser → how many of theirs are counted in pending
125 earned int64 // all weeks' winning bids, paid to the treasury
126)
127
128func init(cur realm) {
129 if cur.Address() != chain.PackageAddress(pkgPath) {
130 panic("adstation: deployed at a path other than pkgPath")
131 }
132 approvers.Set(coldKey.String(), true)
133}
134
135// ---------------------------------------------------------------- time --
136
137// WeekOf is the start of the week holding t (unix seconds).
138func WeekOf(t int64) int64 {
139 if t < Monday0 {
140 return Monday0
141 }
142 return Monday0 + (t-Monday0)/WeekLen*WeekLen
143}
144
145// ThisWeek is the start of the week running now; NextWeek the one after it.
146func ThisWeek() int64 { return WeekOf(time.Now().Unix()) }
147func NextWeek() int64 { return ThisWeek() + WeekLen }
148
149// Closes is when bidding for a week ends.
150func Closes(week int64) int64 { return week - CloseAhead }
151
152func isWeek(w int64) bool { return w >= Monday0 && (w-Monday0)%WeekLen == 0 }
153
154// ---------------------------------------------------------------- reading --
155
156func Owner() address { return owner }
157func NextOwner() address { return nextOwner }
158func IsApprover(a address) bool { return approvers.Has(a.String()) }
159func MinBid() int64 { return minBid }
160func Panels() int { return panels }
161func Paused() bool { return paused }
162func Earned() int64 { return earned }
163func Treasury() address { return adtreasury.Address() } // imported, so this realm cannot go on chain before the treasury
164func Address() address { return chain.PackageAddress(pkgPath) }
165
166// Owed is what an address could withdraw now: refunds of lost and turned-down bids.
167func Owed(a address) int64 {
168 v := owed.Get(a.String())
169 if v == nil {
170 return 0
171 }
172 return v.(int64)
173}
174
175// GetAd and GetBid are copies.
176func GetAd(id int64) Ad { return *ad(id) }
177func GetBid(id int64) Offer { return *bid(id) }
178
179// Approvers lists the approvers.
180func Approvers() []address {
181 var out []address
182 approvers.Iterate("", "", func(k string, _ any) bool {
183 out = append(out, address(k))
184 return false
185 })
186 return out
187}
188
189func pad(n int64) string {
190 s := strconv.FormatInt(n, 10)
191 return strings.Repeat("0", 20-len(s)) + s
192}
193
194func ad(id int64) *Ad {
195 v := ads.Get(pad(id))
196 if v == nil {
197 panic("adstation: no such ad")
198 }
199 return v.(*Ad)
200}
201
202func bid(id int64) *Offer {
203 v := bids.Get(pad(id))
204 if v == nil {
205 panic("adstation: no such bid")
206 }
207 return v.(*Offer)
208}
209
210func weekRec(w int64) *Week {
211 v := weeks.Get(pad(w))
212 if v == nil {
213 return nil
214 }
215 return v.(*Week)
216}
217
218// ---------------------------------------------------------------- callers and money --
219
220func caller(cur realm) address {
221 if !cur.IsCurrent() {
222 panic("adstation: spoofed realm")
223 }
224 return cur.Previous().Address()
225}
226
227// payer is the signer of a direct call carrying GNOT: a payment entry point refuses relays (MsgRun, another realm),
228// which could spend the origin's coins before calling here (gno-security-guide §5.5).
229func payer(cur realm) address {
230 if !cur.IsCurrent() {
231 panic("adstation: spoofed realm")
232 }
233 prev := cur.Previous()
234 if !prev.IsUserCall() {
235 panic("adstation: bid in a direct call from your wallet")
236 }
237 return prev.Address()
238}
239
240// sent is the ugnot carried by the call; any other coin is refused.
241func sent() int64 {
242 coins := unsafe.OriginSend()
243 for _, c := range coins {
244 if c.Denom != GNOT {
245 panic("adstation: only GNOT is accepted")
246 }
247 }
248 return coins.AmountOf(GNOT)
249}
250
251func credit(to address, ugnot int64) {
252 if ugnot <= 0 {
253 return
254 }
255 owed.Set(to.String(), Owed(to)+ugnot)
256}
257
258func pay(cur realm, to address, ugnot int64) {
259 banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(Address(), to, chain.Coins{{GNOT, ugnot}})
260}
261
262// ---------------------------------------------------------------- bidding --
263
264// Bid opens an ad (its picture by hash, a link, a title) and bids for it in one call: the GNOT sent is the bid, for
265// the panels named ("3", "0-8", "0-8,27-35") in the week starting at week (a Monday 00:00 UTC, unix seconds).
266// Returns the bid's id; the ad's id is GetBid(id).Ad.
267func Bid(cur realm, week int64, panelSpec string, image, link, title string) int64 {
268 who := payer(cur)
269 ugnot := sent()
270 checkImage(image)
271 checkLink(link)
272 checkTitle(title)
273 queueRoom(who)
274 n, bits, count, bump := admit(who, week, panelSpec, ugnot) // every check before anything is written
275 lastAd++
276 a := &Ad{ID: lastAd, Owner: who, Image: image, Link: link, Title: title, State: "pending", Created: time.Now().Unix()}
277 ads.Set(pad(a.ID), a)
278 appendTo(&byOwner, who, a.ID)
279 chain.Emit("AdMade", "id", strconv.FormatInt(a.ID, 10), "by", who.String(), "image", image)
280 id := place(who, a, week, n, bits, count, ugnot, bump)
281 queue(a)
282 return id
283}
284
285// BidAd bids again with an ad of yours, approved or still waiting: the GNOT sent is the bid.
286func BidAd(cur realm, adID int64, week int64, panelSpec string) int64 {
287 who := payer(cur)
288 ugnot := sent()
289 a := ad(adID)
290 if a.Owner != who {
291 panic("adstation: that ad is someone else's")
292 }
293 if a.State == "rejected" {
294 panic("adstation: that ad was turned down")
295 }
296 if len(a.open) >= MaxOpenBidsAd {
297 panic("adstation: that ad has too many open bids")
298 }
299 if a.State == "pending" && !a.queued { // a waiting ad whose bids were all decided comes back into the queue with this one
300 queueRoom(who)
301 }
302 n, bits, count, bump := admit(who, week, panelSpec, ugnot)
303 id := place(who, a, week, n, bits, count, ugnot, bump)
304 queue(a)
305 return id
306}
307
308// appendTo adds an id to an address's list in an index. Only the latest listMax are ever shown, so the list keeps at
309// most twice that: a bid costs the same however many came before it (review round 2, R2-5: the whole history was read
310// and written on every bid).
311func appendTo(t *avl.Tree, who address, id int64) {
312 var list []int64
313 if v := t.Get(who.String()); v != nil {
314 list = v.([]int64)
315 }
316 list = append(list, id)
317 if len(list) > 2*listMax {
318 list = append([]int64(nil), list[len(list)-listMax:]...)
319 }
320 t.Set(who.String(), list)
321}
322
323// THE APPROVERS' QUEUE counts only the waiting ads that have a bid at stake (the review of 2026-10-14, F2: an ad whose
324// bids had all been decided still held one of the queue's places, so a hundred of them, every GNOT already refunded,
325// shut the queue for nothing), at most MaxPendingOwner of them one advertiser's. queueRoom checks before anything is
326// written; queue and unqueue keep the counts.
327func queueRoom(who address) {
328 if pending >= MaxPending {
329 panic("adstation: too many ads are waiting for an approver; try again later")
330 }
331 if waitingOf(who) >= MaxPendingOwner {
332 panic("adstation: you have " + strconv.Itoa(MaxPendingOwner) + " ads waiting for an approver already")
333 }
334}
335
336func waitingOf(o address) int {
337 v := pendingBy.Get(o.String())
338 if v == nil {
339 return 0
340 }
341 return v.(int)
342}
343
344func queue(a *Ad) {
345 if a.queued || a.State != "pending" || len(a.open) == 0 {
346 return
347 }
348 a.queued = true
349 pending++
350 pendingBy.Set(a.Owner.String(), waitingOf(a.Owner)+1)
351}
352
353func unqueue(a *Ad) {
354 if !a.queued {
355 return
356 }
357 a.queued = false
358 pending--
359 if n := waitingOf(a.Owner) - 1; n > 0 {
360 pendingBy.Set(a.Owner.String(), n)
361 } else {
362 pendingBy.Remove(a.Owner.String())
363 }
364}
365
366// admit checks a bid before anything is written: the pause, the least bid, the week (a Monday, still open, not too
367// far ahead), the bidder's open bids in it, room for another bid, and the panels against the week's count. Returns
368// that count, the parsed set, and the bid a full week gives up for this one (0 for none).
369func admit(who address, week int64, panelSpec string, ugnot int64) (int, []uint64, int, int64) {
370 if paused {
371 panic("adstation: bidding is paused")
372 }
373 if ugnot < minBid {
374 panic("adstation: the least bid is " + gnot(minBid))
375 }
376 now := time.Now().Unix()
377 if !isWeek(week) {
378 panic("adstation: a week is named by its start, a Monday 00:00 UTC")
379 }
380 if now >= Closes(week) {
381 panic("adstation: bidding for that week has closed")
382 }
383 if week > ThisWeek()+AheadWeeks*WeekLen {
384 panic("adstation: that week is not open yet")
385 }
386 n, bump := panels, int64(0)
387 if w := weekRec(week); w != nil {
388 n = w.N
389 if w.per[who] >= MaxBidsBidderWeek {
390 panic("adstation: you hold " + strconv.Itoa(MaxBidsBidderWeek) + " open bids in that week already; raise one instead")
391 }
392 // A FULL WEEK TAKES A LARGER BID (the review of 2026-10-14, F1: a week's places, once filled, stayed filled — even by
393 // bids an approver had turned down — so the leader could fill the week and keep it): the new bid takes the place of the
394 // week's smallest open bid of an ad not yet approved (the newest of equals), which is refunded; a bid no larger than
395 // that one is refused. An approved ad's bid keeps its place (round 2, R2-1: bids of ads nobody approved bumped approved
396 // bids in the minute before the close, then lost and were refunded in full). The scan runs only when the week is full.
397 if len(w.bids) >= MaxBidsWeek {
398 low := -1 // the smallest bid of an ad not approved; listed in id order: the later of equal amounts is the newer bid
399 for i := range w.amts {
400 if !w.appr[i] && (low < 0 || w.amts[i] <= w.amts[low]) {
401 low = i
402 }
403 }
404 if low < 0 {
405 panic("adstation: that week is full of bids for approved ads; a place frees when one is turned down")
406 }
407 if ugnot <= w.amts[low] {
408 panic("adstation: that week is full: bid more than " + gnot(w.amts[low]) + " to take the place of its smallest bid")
409 }
410 bump = w.bids[low]
411 }
412 }
413 bits, count := parsePanels(panelSpec, n)
414 return n, bits, count, bump
415}
416
417
418// place writes an admitted bid, after the bid it bumps (if any) is refunded and out of the week.
419func place(who address, a *Ad, week int64, n int, bits []uint64, count int, ugnot int64, bump int64) int64 {
420 if bump != 0 {
421 b := bid(bump)
422 b.State = "bumped"
423 credit(b.Bidder, b.Amount)
424 unlist(b)
425 dropOpen(b)
426 chain.Emit("Bumped", "id", strconv.FormatInt(b.ID, 10))
427 }
428 w := weekRec(week)
429 if w == nil {
430 w = &Week{Start: week, N: n}
431 weeks.Set(pad(week), w)
432 }
433 now := time.Now().Unix()
434 lastBid++
435 b := &Offer{ID: lastBid, Week: week, Ad: a.ID, Bidder: who, Panels: canonical(bits, w.N), Count: count, Amount: ugnot, Placed: now, State: "open", bits: bits}
436 bids.Set(pad(b.ID), b)
437 appendTo(&byBidder, who, b.ID)
438 w.bids = append(w.bids, b.ID)
439 w.amts = append(w.amts, b.Amount)
440 w.appr = append(w.appr, a.State == "approved")
441 if w.per == nil {
442 w.per = map[address]int{}
443 }
444 w.per[who]++
445 a.open = append(a.open, b.ID)
446 chain.Emit("Bid", "id", strconv.FormatInt(b.ID, 10), "week", strconv.FormatInt(week, 10), "ad", strconv.FormatInt(a.ID, 10), "by", who.String(), "panels", b.Panels, "ugnot", strconv.FormatInt(ugnot, 10))
447 return b.ID
448}
449
450// Raise adds the GNOT sent to an open bid of yours, before its week closes.
451func Raise(cur realm, bidID int64) {
452 who := payer(cur)
453 ugnot := sent()
454 b := bid(bidID)
455 if b.Bidder != who {
456 panic("adstation: that bid is someone else's")
457 }
458 if b.State != "open" {
459 panic("adstation: that bid is no longer open")
460 }
461 if paused {
462 panic("adstation: bidding is paused")
463 }
464 if time.Now().Unix() >= Closes(b.Week) {
465 panic("adstation: bidding for that week has closed")
466 }
467 if ugnot <= 0 {
468 panic("adstation: send the GNOT to add")
469 }
470 b.Amount += ugnot
471 if w := weekRec(b.Week); w != nil {
472 for i, id := range w.bids {
473 if id == b.ID {
474 w.amts[i] = b.Amount
475 }
476 }
477 }
478 chain.Emit("Raised", "id", strconv.FormatInt(b.ID, 10), "ugnot", strconv.FormatInt(b.Amount, 10))
479}
480
481// ---------------------------------------------------------------- approving --
482
483func mustApprove(cur realm) address {
484 who := caller(cur)
485 if !IsApprover(who) {
486 panic("adstation: only an approver")
487 }
488 return who
489}
490
491// Approve passes a waiting ad: its bids compete when their weeks settle.
492func Approve(cur realm, adID int64) {
493 who := mustApprove(cur)
494 a := ad(adID)
495 if a.State != "pending" {
496 panic("adstation: that ad is not waiting")
497 }
498 unqueue(a)
499 a.State, a.By, a.At = "approved", who, time.Now().Unix()
500 for _, id := range a.open { // its listed bids now hold their places
501 b := bid(id)
502 if w := weekRec(b.Week); w != nil {
503 for i, x := range w.bids {
504 if x == id {
505 w.appr[i] = true
506 }
507 }
508 }
509 }
510 chain.Emit("Approved", "id", strconv.FormatInt(a.ID, 10), "by", who.String())
511}
512
513// Reject turns an ad down, waiting or approved, with a reason (plain text): every open bid of it is refunded now and
514// leaves its week, whose place it frees. Weeks it already won stay paid.
515func Reject(cur realm, adID int64, reason string) {
516 who := mustApprove(cur)
517 checkReason(reason)
518 a := ad(adID)
519 if a.State == "rejected" {
520 panic("adstation: that ad was already turned down")
521 }
522 reject(a, who, reason)
523}
524
525// RejectMany turns down several ads in one call ("4,9,12", at most 50), with one reason; ads already turned down are
526// passed over. Returns how many it turned down.
527func RejectMany(cur realm, adIDs string, reason string) int {
528 who := mustApprove(cur)
529 checkReason(reason)
530 parts := strings.Split(adIDs, ",")
531 if adIDs == "" || len(parts) > maxRejectMany {
532 panic("adstation: name 1 to " + strconv.Itoa(maxRejectMany) + " ads, like 4,9,12")
533 }
534 var list []*Ad
535 for _, p := range parts { // every number checked before any ad is turned down
536 id, err := strconv.ParseInt(strings.TrimSpace(p), 10, 64)
537 if err != nil {
538 panic("adstation: ads are named by their numbers, like 4,9,12")
539 }
540 list = append(list, ad(id))
541 }
542 n := 0
543 for _, a := range list {
544 if a.State == "rejected" {
545 continue
546 }
547 reject(a, who, reason)
548 n++
549 }
550 return n
551}
552
553func checkReason(reason string) {
554 if len(reason) > maxReason {
555 panic("adstation: the reason is at most " + strconv.Itoa(maxReason) + " characters")
556 }
557 plain(reason, "the reason")
558}
559
560func reject(a *Ad, who address, reason string) {
561 unqueue(a)
562 a.State, a.Reason, a.By, a.At = "rejected", reason, who, time.Now().Unix()
563 for _, id := range a.open {
564 b := bid(id)
565 if b.State == "open" {
566 b.State = "refunded"
567 credit(b.Bidder, b.Amount)
568 unlist(b)
569 }
570 }
571 a.open = nil
572 chain.Emit("Rejected", "id", strconv.FormatInt(a.ID, 10), "by", who.String())
573}
574
575// unlist takes a bid out of its week's list (it stays readable by GetBid): a refunded bid holds no place there, and no
576// longer counts among its bidder's.
577func unlist(b *Offer) {
578 w := weekRec(b.Week)
579 if w == nil {
580 return
581 }
582 if w.per[b.Bidder] > 1 {
583 w.per[b.Bidder]--
584 } else {
585 delete(w.per, b.Bidder)
586 }
587 keep, amts, appr := make([]int64, 0, len(w.bids)), make([]int64, 0, len(w.amts)), make([]bool, 0, len(w.appr))
588 for i, id := range w.bids {
589 if id != b.ID {
590 keep, amts, appr = append(keep, id), append(amts, w.amts[i]), append(appr, w.appr[i])
591 }
592 }
593 w.bids, w.amts, w.appr = keep, amts, appr
594}
595
596// ---------------------------------------------------------------- settling --
597
598// Settle closes the books of a week that has started (anyone may call it, once): it picks the winners among the open
599// bids whose ad is approved (allocate), pays the winners' GNOT to the treasury and credits everyone else's back.
600// Returns the winning bids' total.
601func Settle(cur realm, week int64) int64 {
602 caller(cur)
603 if !isWeek(week) {
604 panic("adstation: a week is named by its start, a Monday 00:00 UTC")
605 }
606 if time.Now().Unix() < week {
607 panic("adstation: that week has not started")
608 }
609 w := weekRec(week)
610 if w == nil {
611 panic("adstation: nobody bid for that week")
612 }
613 if w.Settled {
614 panic("adstation: that week is settled")
615 }
616 won := allocate(w)
617 isWon := map[int64]bool{}
618 for _, id := range won {
619 isWon[id] = true
620 }
621 var paid int64
622 for _, id := range w.bids {
623 b := bid(id)
624 if b.State != "open" {
625 continue
626 }
627 if isWon[id] {
628 b.State = "won"
629 paid += b.Amount
630 } else {
631 b.State = "lost"
632 credit(b.Bidder, b.Amount)
633 }
634 dropOpen(b)
635 }
636 w.Settled, w.won, w.Paid, w.per = true, won, paid, nil
637 earned += paid
638 if paid > 0 {
639 pay(cur, Treasury(), paid)
640 }
641 chain.Emit("Settled", "week", strconv.FormatInt(week, 10), "winners", strconv.Itoa(len(won)), "ugnot", strconv.FormatInt(paid, 10))
642 return paid
643}
644
645func dropOpen(b *Offer) {
646 a := ad(b.Ad)
647 keep := make([]int64, 0, len(a.open))
648 for _, id := range a.open {
649 if id != b.ID {
650 keep = append(keep, id)
651 }
652 }
653 a.open = keep
654 if len(a.open) == 0 {
655 unqueue(a) // nothing at stake: it leaves the approvers' queue until it bids again
656 }
657}
658
659// allocate picks a week's winners among its open bids for approved ads: each in turn of an order wins if none of its
660// panels is taken yet, and of two orders — price per panel (cross-multiplied, so no rounding), and the whole amount —
661// the one that pays the treasury more wins (then the one that sells more panels, then price per panel). THE REVIEW OF
662// 2026-10-14, F3: by price per panel alone, one least bid for one panel placed a minute before the close voided a
663// bid five times its size for the whole ring, and the treasury took the small one with 107 panels dark. Deterministic.
664func allocate(w *Week) []int64 {
665 var byP, byA []*Offer
666 for _, id := range w.bids {
667 b := bid(id)
668 if b.State == "open" && ad(b.Ad).State == "approved" {
669 byP = append(byP, b)
670 byA = append(byA, b)
671 }
672 }
673 sort.Sort(byPrice(byP))
674 sort.Sort(byAmount(byA))
675 wonP, paidP, soldP := greedy(byP, w.N)
676 wonA, paidA, soldA := greedy(byA, w.N)
677 if paidA > paidP || paidA == paidP && soldA > soldP {
678 return wonA
679 }
680 return wonP
681}
682
683// greedy takes the bids in order, each if none of its panels is taken: the winners, what they pay, the panels sold.
684func greedy(order []*Offer, n int) ([]int64, int64, int) {
685 taken := make([]uint64, words(n))
686 var won []int64
687 var paid int64
688 sold := 0
689 for _, b := range order {
690 if overlaps(taken, b.bits) {
691 continue
692 }
693 for i := range taken {
694 taken[i] |= b.bits[i]
695 }
696 won = append(won, b.ID)
697 paid += b.Amount
698 sold += b.Count
699 }
700 return won, paid, sold
701}
702
703// byAmount orders bids by the whole amount, then price per panel, then the earlier id.
704type byAmount []*Offer
705
706func (p byAmount) Len() int { return len(p) }
707func (p byAmount) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
708func (p byAmount) Less(i, j int) bool {
709 x, y := p[i], p[j]
710 if x.Amount != y.Amount {
711 return x.Amount > y.Amount
712 }
713 l, r := x.Amount*int64(y.Count), y.Amount*int64(x.Count)
714 if l != r {
715 return l > r
716 }
717 return x.ID < y.ID
718}
719
720// byPrice orders bids best first: price per panel (cross-multiplied, no rounding), then amount, then the earlier id.
721type byPrice []*Offer
722
723func (p byPrice) Len() int { return len(p) }
724func (p byPrice) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
725func (p byPrice) Less(i, j int) bool {
726 x, y := p[i], p[j]
727 l, r := x.Amount*int64(y.Count), y.Amount*int64(x.Count)
728 if l != r {
729 return l > r
730 }
731 if x.Amount != y.Amount {
732 return x.Amount > y.Amount
733 }
734 return x.ID < y.ID
735}
736
737// Winners is the week's winning bids: final once it is settled, else as things stand now.
738func Winners(week int64) []int64 {
739 w := weekRec(week)
740 if w == nil {
741 return nil
742 }
743 if w.Settled {
744 return append([]int64(nil), w.won...)
745 }
746 return allocate(w)
747}
748
749// ---------------------------------------------------------------- refunds --
750
751// Withdraw pays you what you are owed.
752func Withdraw(cur realm) int64 {
753 who := caller(cur)
754 return payOut(cur, who)
755}
756
757// PayOut pays an address what it is owed; anyone may call it for anyone (the money has one destination).
758func PayOut(cur realm, to address) int64 {
759 caller(cur)
760 return payOut(cur, to)
761}
762
763func payOut(cur realm, to address) int64 {
764 n := Owed(to)
765 if n <= 0 {
766 panic("adstation: nothing is owed to " + to.String())
767 }
768 owed.Remove(to.String())
769 pay(cur, to, n)
770 chain.Emit("Paid", "to", to.String(), "ugnot", strconv.FormatInt(n, 10))
771 return n
772}
773
774// ---------------------------------------------------------------- the owner --
775
776func mustOwn(cur realm) address {
777 who := caller(cur)
778 if who != owner {
779 panic("adstation: only the owner")
780 }
781 return who
782}
783
784// AddApprover and RemoveApprover name who may pass or turn down ads.
785func AddApprover(cur realm, a address) {
786 mustOwn(cur)
787 if !a.IsValid() {
788 panic("adstation: not an address")
789 }
790 approvers.Set(a.String(), true)
791 chain.Emit("ApproverAdded", "who", a.String())
792}
793
794func RemoveApprover(cur realm, a address) {
795 mustOwn(cur)
796 if _, removed := approvers.Remove(a.String()); !removed {
797 panic("adstation: not an approver")
798 }
799 chain.Emit("ApproverRemoved", "who", a.String())
800}
801
802// SetMinBid sets the least bid, in ugnot, for bids placed from now on.
803func SetMinBid(cur realm, ugnot int64) {
804 mustOwn(cur)
805 if ugnot <= 0 {
806 panic("adstation: the least bid must be positive")
807 }
808 minBid = ugnot
809 chain.Emit("MinBid", "ugnot", strconv.FormatInt(ugnot, 10))
810}
811
812// SetPanels sets the panel count of weeks nobody has bid on yet (the station's panels can change; a week keeps the
813// count it had at its first bid).
814func SetPanels(cur realm, n int) {
815 mustOwn(cur)
816 if n < 1 || n > MaxPanels {
817 panic("adstation: panels are between 1 and " + strconv.Itoa(MaxPanels))
818 }
819 panels = n
820 chain.Emit("Panels", "n", strconv.Itoa(n))
821}
822
823// SetPaused stops (true) or resumes new bids and raises; approving, settling and refunds go on.
824func SetPaused(cur realm, p bool) {
825 mustOwn(cur)
826 paused = p
827 chain.Emit("Paused", "paused", strconv.FormatBool(p))
828}
829
830// SetOwner names the next owner; nothing changes until they AcceptOwner. "" withdraws the offer.
831func SetOwner(cur realm, to address) {
832 mustOwn(cur)
833 if to != "" && !to.IsValid() {
834 panic("adstation: not an address")
835 }
836 nextOwner = to
837 chain.Emit("OwnerNamed", "next", to.String())
838}
839
840// AcceptOwner takes the owner role, by the one named (a user or a realm).
841func AcceptOwner(cur realm) {
842 who := caller(cur)
843 if nextOwner == "" || who != nextOwner {
844 panic("adstation: not the one named")
845 }
846 prev := owner
847 owner, nextOwner = who, ""
848 chain.Emit("OwnerChanged", "from", prev.String(), "to", who.String())
849}
850
851// ---------------------------------------------------------------- checks --
852
853func checkImage(s string) {
854 if len(s) != 64 {
855 panic("adstation: the image is its sha256, 64 hex characters")
856 }
857 for i := 0; i < len(s); i++ {
858 c := s[i]
859 if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
860 panic("adstation: the image is its sha256, 64 lowercase hex characters")
861 }
862 }
863}
864
865// checkLink: https only, from a character set that cannot break the pages (markdown, JSON) that show it.
866func checkLink(s string) {
867 if len(s) > maxLink {
868 panic("adstation: the link is at most " + strconv.Itoa(maxLink) + " characters")
869 }
870 if !strings.HasPrefix(s, "https://") || len(s) <= len("https://") {
871 panic("adstation: the link starts with https://")
872 }
873 for i := 0; i < len(s); i++ {
874 c := s[i]
875 if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte("-._~:/?#@!$&+,;=%", c) >= 0 {
876 continue
877 }
878 panic("adstation: the link has a character it cannot carry")
879 }
880}
881
882func checkTitle(s string) {
883 if strings.TrimSpace(s) == "" || len(s) > maxTitle {
884 panic("adstation: the title is 1 to " + strconv.Itoa(maxTitle) + " characters")
885 }
886 plain(s, "the title")
887}
888
889// plain refuses control characters and anything the pages would read as markup or that breaks their JSON.
890func plain(s, what string) {
891 for i := 0; i < len(s); i++ {
892 if s[i] < 0x20 || s[i] == 0x7f || strings.IndexByte("[]()<>`*_#|\\!~\"", s[i]) >= 0 {
893 panic("adstation: " + what + " is plain text without markup")
894 }
895 }
896}
897
898// ---------------------------------------------------------------- panels --
899
900func words(n int) int { return (n + 63) / 64 }
901
902// parsePanels reads "3", "0-8", "0-8,27-35" against n panels: the set as bits and how many.
903func parsePanels(s string, n int) ([]uint64, int) {
904 if s == "" || len(s) > maxPanelsSpec {
905 panic("adstation: name the panels, e.g. 0-8,27-35")
906 }
907 bits := make([]uint64, words(n))
908 for _, part := range strings.Split(s, ",") {
909 lo, hi := part, part
910 if i := strings.IndexByte(part, '-'); i >= 0 {
911 lo, hi = part[:i], part[i+1:]
912 }
913 a, err1 := strconv.Atoi(lo)
914 b, err2 := strconv.Atoi(hi)
915 if err1 != nil || err2 != nil || a < 0 || b < a || b >= n {
916 panic("adstation: panels are numbers 0 to " + strconv.Itoa(n-1) + ", like 0-8,27-35")
917 }
918 for p := a; p <= b; p++ {
919 bits[p/64] |= 1 << uint(p%64)
920 }
921 }
922 count := 0
923 for _, x := range bits {
924 for ; x != 0; x &= x - 1 {
925 count++
926 }
927 }
928 return bits, count
929}
930
931func overlaps(a, b []uint64) bool {
932 for i := range a {
933 if a[i]&b[i] != 0 {
934 return true
935 }
936 }
937 return false
938}
939
940// canonical writes a set of panels as sorted ranges: "0-8,27-35".
941func canonical(bits []uint64, n int) string {
942 var out []string
943 for p := 0; p < n; p++ {
944 if bits[p/64]&(1<<uint(p%64)) == 0 {
945 continue
946 }
947 q := p
948 for q+1 < n && bits[(q+1)/64]&(1<<uint((q+1)%64)) != 0 {
949 q++
950 }
951 if q == p {
952 out = append(out, strconv.Itoa(p))
953 } else {
954 out = append(out, strconv.Itoa(p)+"-"+strconv.Itoa(q))
955 }
956 p = q
957 }
958 return strings.Join(out, ",")
959}
960
961// ---------------------------------------------------------------- pages --
962
963// Render: "" the station's market; "json" its settings; "json/week/<start>" a week's bids and winners;
964// "json/ad/<id>" an ad; "json/pending" the ads waiting; "json/owed" who is owed refunds; "json/by/<address>" one
965// address's own ads and bids, every state, and what it is owed.
966func Render(path string) string {
967 parts := strings.Split(path, "/")
968 switch {
969 case path == "":
970 return home()
971 case path == "json":
972 return stateJSON()
973 case path == "json/pending":
974 return pendingJSON()
975 case path == "json/owed":
976 return owedJSON()
977 case len(parts) == 3 && parts[0] == "json" && parts[1] == "by":
978 a := address(parts[2])
979 if !a.IsValid() { // echo only a real address
980 return "404"
981 }
982 return byJSON(a)
983 case len(parts) == 3 && parts[0] == "json" && parts[1] == "week":
984 w, err := strconv.ParseInt(parts[2], 10, 64)
985 if err != nil || !isWeek(w) {
986 return "404"
987 }
988 return weekJSON(w)
989 case len(parts) == 3 && parts[0] == "json" && parts[1] == "ad":
990 id, err := strconv.ParseInt(parts[2], 10, 64)
991 if err != nil || id < 1 || id > lastAd {
992 return "404"
993 }
994 return adJSON(ad(id))
995 }
996 return "404"
997}
998
999func q(s string) string { return `"` + s + `"` } // only for text the checks above keep free of quotes and backslashes
1000
1001func i64(n int64) string { return strconv.FormatInt(n, 10) }
1002
1003func stateJSON() string {
1004 var b strings.Builder
1005 b.WriteString(`{"now":` + i64(time.Now().Unix()) + `,"thisWeek":` + i64(ThisWeek()) + `,"minBid":` + i64(minBid) + `,"panels":` + strconv.Itoa(panels) +
1006 `,"paused":` + strconv.FormatBool(paused) + `,"owner":` + q(owner.String()) + `,"treasury":` + q(Treasury().String()) + `,"earned":` + i64(earned) +
1007 `,"ads":` + i64(lastAd) + `,"bids":` + i64(lastBid) + `,"pending":` + strconv.Itoa(pending) + `,"closeAhead":` + i64(CloseAhead) + `,"aheadWeeks":` + i64(AheadWeeks) + `,"approvers":[`)
1008 for i, a := range Approvers() {
1009 if i > 0 {
1010 b.WriteString(",")
1011 }
1012 b.WriteString(q(a.String()))
1013 }
1014 b.WriteString(`],"weeks":[`)
1015 first := true
1016 for w := ThisWeek(); w <= ThisWeek()+AheadWeeks*WeekLen; w += WeekLen {
1017 if weekRec(w) == nil {
1018 continue
1019 }
1020 if !first {
1021 b.WriteString(",")
1022 }
1023 first = false
1024 b.WriteString(i64(w))
1025 }
1026 // THE WEEKS THAT STARTED AND WERE NEVER SETTLED (the review of 2026-10-14, F4: a week the keeper missed dropped out of
1027 // this list while its bids stayed open, their GNOT held): the latest ten, so the box finds them and anyone can Settle.
1028 b.WriteString(`],"unsettled":` + ids(Unsettled()) + "}")
1029 return b.String()
1030}
1031
1032// Unsettled is the latest ten weeks that have started, hold bids, and were never settled.
1033func Unsettled() []int64 {
1034 var out []int64
1035 now := ThisWeek()
1036 weeks.ReverseIterate("", "", func(_ string, v any) bool {
1037 w := v.(*Week)
1038 if w.Start < now && !w.Settled && len(w.bids) > 0 {
1039 out = append(out, w.Start)
1040 }
1041 return len(out) >= 10
1042 })
1043 return out
1044}
1045
1046func bidJSON(x *Offer) string {
1047 return `{"id":` + i64(x.ID) + `,"week":` + i64(x.Week) + `,"ad":` + i64(x.Ad) + `,"bidder":` + q(x.Bidder.String()) + `,"panels":` + q(x.Panels) +
1048 `,"count":` + strconv.Itoa(x.Count) + `,"amount":` + i64(x.Amount) + `,"placed":` + i64(x.Placed) + `,"state":` + q(x.State) + `}`
1049}
1050
1051func adJSON(a *Ad) string {
1052 return `{"id":` + i64(a.ID) + `,"owner":` + q(a.Owner.String()) + `,"image":` + q(a.Image) + `,"link":` + q(a.Link) + `,"title":` + q(a.Title) +
1053 `,"state":` + q(a.State) + `,"reason":` + q(a.Reason) + `,"by":` + q(a.By.String()) + `,"at":` + i64(a.At) + `,"created":` + i64(a.Created) + `,"openBids":` + strconv.Itoa(len(a.open)) + `,"queued":` + strconv.FormatBool(a.queued) + `}`
1054}
1055
1056func ids(xs []int64) string {
1057 var b strings.Builder
1058 b.WriteString("[")
1059 for i, x := range xs {
1060 if i > 0 {
1061 b.WriteString(",")
1062 }
1063 b.WriteString(i64(x))
1064 }
1065 b.WriteString("]")
1066 return b.String()
1067}
1068
1069func weekJSON(start int64) string {
1070 w := weekRec(start)
1071 if w == nil {
1072 return `{"start":` + i64(start) + `,"closes":` + i64(Closes(start)) + `,"n":` + strconv.Itoa(panels) + `,"settled":false,"paid":0,"bids":[],"winners":[],"ads":[]}`
1073 }
1074 var b strings.Builder
1075 b.WriteString(`{"start":` + i64(start) + `,"closes":` + i64(Closes(start)) + `,"n":` + strconv.Itoa(w.N) + `,"settled":` + strconv.FormatBool(w.Settled) + `,"paid":` + i64(w.Paid) + `,"bids":[`)
1076 seen := map[int64]bool{}
1077 var adIDs []int64
1078 for i, id := range w.bids {
1079 if i > 0 {
1080 b.WriteString(",")
1081 }
1082 x := bid(id)
1083 b.WriteString(bidJSON(x))
1084 if !seen[x.Ad] {
1085 seen[x.Ad] = true
1086 adIDs = append(adIDs, x.Ad)
1087 }
1088 }
1089 b.WriteString(`],"winners":` + ids(Winners(start)) + `,"ads":[`)
1090 for i, id := range adIDs {
1091 if i > 0 {
1092 b.WriteString(",")
1093 }
1094 b.WriteString(adJSON(ad(id)))
1095 }
1096 b.WriteString("]}")
1097 return b.String()
1098}
1099
1100func pendingJSON() string {
1101 var b strings.Builder
1102 b.WriteString("[")
1103 n := 0
1104 ads.ReverseIterate("", "", func(_ string, v any) bool {
1105 a := v.(*Ad)
1106 if a.State != "pending" || !a.queued { // waiting with a bid at stake: the approvers' queue
1107 return false
1108 }
1109 if n > 0 {
1110 b.WriteString(",")
1111 }
1112 b.WriteString(adJSON(a))
1113 n++
1114 return n >= MaxPending
1115 })
1116 b.WriteString("]")
1117 return b.String()
1118}
1119
1120// byJSON is one address's own record: its latest ads (with their state and any reason they were turned down) and bids
1121// (every state: open, won, lost, bumped, refunded), and what it is owed. The weeks' lists carry only their listed bids;
1122// this is where an advertiser sees what happened to the others.
1123func byJSON(who address) string {
1124 var b strings.Builder
1125 b.WriteString(`{"address":` + q(who.String()) + `,"owed":` + i64(Owed(who)) + `,"ads":[`)
1126 if v := byOwner.Get(who.String()); v != nil {
1127 list := v.([]int64)
1128 from := len(list) - listMax
1129 if from < 0 {
1130 from = 0
1131 }
1132 for i := len(list) - 1; i >= from; i-- {
1133 if i < len(list)-1 {
1134 b.WriteString(",")
1135 }
1136 b.WriteString(adJSON(ad(list[i])))
1137 }
1138 }
1139 b.WriteString(`],"bids":[`)
1140 if v := byBidder.Get(who.String()); v != nil {
1141 list := v.([]int64)
1142 from := len(list) - listMax
1143 if from < 0 {
1144 from = 0
1145 }
1146 for i := len(list) - 1; i >= from; i-- {
1147 if i < len(list)-1 {
1148 b.WriteString(",")
1149 }
1150 b.WriteString(bidJSON(bid(list[i])))
1151 }
1152 }
1153 b.WriteString("]}")
1154 return b.String()
1155}
1156
1157func owedJSON() string {
1158 var b strings.Builder
1159 b.WriteString("[")
1160 n := 0
1161 owed.Iterate("", "", func(k string, v any) bool {
1162 if n > 0 {
1163 b.WriteString(",")
1164 }
1165 b.WriteString(`{"to":` + q(k) + `,"ugnot":` + i64(v.(int64)) + `}`)
1166 n++
1167 return n >= 500
1168 })
1169 b.WriteString("]")
1170 return b.String()
1171}
1172
1173func home() string {
1174 var b strings.Builder
1175 b.WriteString("# The ad station\n\n")
1176 b.WriteString("gno.world's ad station sells its panels by the week, for GNOT, at ads.gno.world. Name any panels, upload your picture, bid; an approver passes the ad; the bids close a day before each Monday (00:00 UTC), and when the week starts the approved bids are ranked two ways, by price per panel and by the whole amount, each in turn winning its panels if none is taken, and the way that pays more decides the week. Every bid that does not win is refunded in full.\n\n")
1177 b.WriteString("- least bid: " + gnot(minBid) + " · panels: " + strconv.Itoa(panels) + " · weeks open: " + i64(AheadWeeks))
1178 if paused {
1179 b.WriteString(" · **bidding paused**")
1180 }
1181 b.WriteString("\n- earned so far: " + gnot(earned) + ", paid to the station's treasury " + Treasury().String() + "\n")
1182 b.WriteString("- ads waiting for an approver: " + strconv.Itoa(pending) + "\n\n")
1183 b.WriteString("## This week\n\n")
1184 writeWinners(&b, ThisWeek())
1185 for w := NextWeek(); w <= ThisWeek()+AheadWeeks*WeekLen; w += WeekLen {
1186 if weekRec(w) == nil {
1187 continue
1188 }
1189 b.WriteString("\n## Week of " + time.Unix(w, 0).UTC().Format("2006-01-02") + " (bids close " + time.Unix(Closes(w), 0).UTC().Format("Jan 2 15:04") + " UTC; as things stand)\n\n")
1190 writeWinners(&b, w)
1191 }
1192 return b.String()
1193}
1194
1195func writeWinners(b *strings.Builder, w int64) {
1196 won := Winners(w)
1197 if len(won) == 0 {
1198 b.WriteString("_No ads._\n")
1199 return
1200 }
1201 for _, id := range won {
1202 x := bid(id)
1203 a := ad(x.Ad)
1204 b.WriteString("- panels `" + x.Panels + "` · " + a.Title + " · `" + a.Link + "` · " + gnot(x.Amount) + "\n")
1205 }
1206}
1207
1208// gnot formats ugnot as GNOT, the trailing zeros trimmed.
1209func gnot(ugnot int64) string {
1210 whole, frac := ugnot/1_000_000, ugnot%1_000_000
1211 if frac == 0 {
1212 return strconv.FormatInt(whole, 10) + " GNOT"
1213 }
1214 f := strconv.FormatInt(1_000_000+frac, 10)[1:]
1215 return strconv.FormatInt(whole, 10) + "." + strings.TrimRight(f, "0") + " GNOT"
1216}