Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}