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

lastwords.gno

9.74 Kb · 319 lines
  1// Package lastwords is one string, on chain, forever.
  2//
  3// There is a single message slot. Pay more than the last writer paid and the
  4// slot is yours; when the clock finally runs out, whatever is in it stays there
  5// permanently. The artefact at the end is the point, so the game is a bidding
  6// war over an epitaph rather than over a prize.
  7//
  8// The three rules exist because the obvious version of this game does not end
  9// and does not pay:
 10//
 11//  1. THE CLOCK TERMINATES. Every write pushes the deadline a fifth of the way
 12//     to a hard end fixed when the slot opened, and no write can push it past
 13//     that. A constant extension per action has no end: the pot grows faster
 14//     than the price of the next write, so there is always a rational next
 15//     write and the game runs until everyone is bored or broke.
 16//  2. THE LAST WRITER DOES NOT TAKE THE POT. They take the slot, which is what
 17//     they were bidding for. The pot is split among everyone ELSE who wrote,
 18//     pro rata to what they paid. Winner-takes-all makes the second-to-last
 19//     writer the mark and everyone knows it, which is why nobody joins.
 20//  3. HALF OF EVERY PAYMENT GOES TO THE WRITER BEING DISPLACED, immediately.
 21//     A zero-sum game minus gas has no reason for a second player. Overwriting
 22//     somebody pays them, so being overwritten early is not a loss.
 23//
 24// Nothing is ever pushed: payouts are credited to an internal ledger and swept
 25// by Withdraw. A realm that pays inside a loop over its roster is a gas bomb
 26// that eventually bricks, and the bigger the game gets the more certain that
 27// is.
 28//
 29// The pieces are libraries, and the realm is the wiring:
 30// [p/moul/x/games/clock](/p/moul/x/games/clock/v0) owns the deadline and its
 31// guards, [p/moul/x/games/prorata](/p/moul/x/games/prorata/v0) owns the split,
 32// and [p/moul/x/daily/pullpayment](/p/moul/x/daily/pullpayment/v0) owns the
 33// credit ledger.
 34package lastwords
 35
 36import (
 37	"strconv"
 38	"strings"
 39
 40	"chain"
 41	"chain/banker"
 42	"chain/runtime"
 43	"chain/runtime/unsafe"
 44
 45	"gno.land/p/moul/x/daily/pullpayment/v0"
 46	"gno.land/p/moul/x/games/clock/v0"
 47	"gno.land/p/moul/x/games/prorata/v0"
 48	"gno.land/p/nt/avl/v0"
 49)
 50
 51const denom = "ugnot"
 52
 53// The rules, fixed at deploy. Times are block heights.
 54const (
 55	// Window is how long the slot stays open with nobody writing.
 56	Window = int64(2000)
 57	// Floor is the minimum a write leaves on the clock, so the last stretch
 58	// stays long enough for a transaction to land in. Without it a decaying
 59	// extension shrinks to nothing and whoever holds at that moment wins by
 60	// being unreachable rather than by paying.
 61	Floor = int64(300)
 62	// Life is the hard end. No write can push the deadline past start+Life.
 63	Life = int64(100000)
 64	// BumpPct is how much of the gap to the hard end a write buys.
 65	BumpPct = int64(20)
 66	// DividendPct is the share of a payment credited to the writer being
 67	// displaced; the rest goes to the pot.
 68	DividendPct = int64(50)
 69
 70	// MinWrite is the base price of the slot, in ugnot.
 71	MinWrite = int64(1000000)
 72	// PerByte is charged on top, in ugnot per byte of the message. The chain's
 73	// own storage deposit is 100 ugnot per byte, so this is that plus a premium
 74	// for occupying the only slot there is.
 75	PerByte = int64(1000)
 76	// MaxLen caps the message in bytes, because the message IS the storage
 77	// cost and an uncapped one is an unbounded write priced at a constant.
 78	MaxLen = 280
 79)
 80
 81var (
 82	clk     *clock.Clock
 83	ledger  = pullpayment.New()
 84	message string
 85	author  address
 86	paid    int64    // what the current holder paid
 87	pot     int64    // what settlement will split
 88	writers avl.Tree // address string -> int64 total paid
 89	order   []string // writers in first-write order, so the split is deterministic
 90	writes  int64
 91	settled bool
 92)
 93
 94func init() {
 95	open(runtime.ChainHeight())
 96}
 97
 98// open resets the game to a fresh slot at height h. It is the only place the
 99// clock is built, so tests and init cannot disagree about the rules.
100func open(h int64) {
101	c, err := clock.New(h, Window, Floor, Life)
102	if err != nil {
103		panic("lastwords: " + err.Error())
104	}
105	clk = c
106	ledger = pullpayment.New()
107	message = ""
108	author = ""
109	paid = 0
110	pot = 0
111	writers = avl.Tree{}
112	order = nil
113	writes = 0
114	settled = false
115}
116
117// Price returns what msg costs to write right now, in ugnot: a base, plus a
118// charge per byte, and always at least one ugnot more than the current holder
119// paid. Query it before sending; an underpaying write is refused, not refunded.
120func Price(msg string) int64 {
121	price := MinWrite + int64(len(msg))*PerByte
122	if next := paid + 1; next > price {
123		price = next
124	}
125	return price
126}
127
128// Write puts msg in the slot and makes the caller its holder. The caller must
129// send at least Price(msg) ugnot with the transaction.
130//
131// Half of the payment is credited to the writer being displaced and the rest
132// joins the pot. The deadline is pushed a fifth of the way toward the hard end,
133// never past it, and never to less than Floor from now.
134//
135// Only a direct user transaction may write, so the payment envelope cannot be
136// spoofed by an ephemeral MsgRun realm.
137func Write(cur realm, msg string) {
138	if !cur.Previous().IsUserCall() {
139		panic("lastwords: write must be a direct user transaction")
140	}
141	h := runtime.ChainHeight()
142	if clk.Expired(h) {
143		panic("lastwords: the slot is closed, these are somebody's last words now")
144	}
145	if msg == "" {
146		panic("lastwords: message must not be empty")
147	}
148	if len(msg) > MaxLen {
149		panic("lastwords: message must be at most " + strconv.Itoa(MaxLen) + " bytes")
150	}
151	if strings.ContainsAny(msg, "\n\r") {
152		panic("lastwords: message must be a single line")
153	}
154
155	price := Price(msg)
156	sent := unsafe.OriginSend().AmountOf(denom)
157	if sent < price {
158		panic("lastwords: sent " + strconv.FormatInt(sent, 10) +
159			" ugnot, price is " + strconv.FormatInt(price, 10))
160	}
161
162	caller := cur.Previous().Address()
163
164	// The displaced writer is paid first, out of the payment that displaced
165	// them. Credited, never sent: see the package doc.
166	dividend := int64(0)
167	if author != "" {
168		dividend = sent * DividendPct / 100
169		if err := ledger.Credit(author.String(), dividend); err != nil {
170			panic("lastwords: " + err.Error())
171		}
172	}
173	pot += sent - dividend
174
175	key := caller.String()
176	if _, seen := writers.Get(key).(int64); !seen {
177		order = append(order, key)
178	}
179	writers.Set(key, totalPaid(key)+sent)
180
181	message = msg
182	author = caller
183	paid = sent
184	writes++
185
186	deadline, err := clk.BumpShare(h, BumpPct)
187	if err != nil {
188		panic("lastwords: " + err.Error())
189	}
190
191	chain.Emit("Write",
192		"author", key,
193		"paid", strconv.FormatInt(sent, 10),
194		"dividend", strconv.FormatInt(dividend, 10),
195		"deadline", strconv.FormatInt(deadline, 10),
196	)
197}
198
199// Settle closes the game once the clock has run out, splitting the pot among
200// every writer EXCEPT the one holding the slot, pro rata to what each paid.
201//
202// Anyone may call it: leaving settlement to an interested party is how a pot
203// stays unclaimed. It moves no coins, it credits the ledger; Withdraw is what
204// pays. If the holder is the only writer there is nobody else to split with,
205// so the pot returns to them.
206func Settle(cur realm) {
207	h := runtime.ChainHeight()
208	if !clk.Expired(h) {
209		panic("lastwords: still open until height " + strconv.FormatInt(clk.Deadline(), 10))
210	}
211	if settled {
212		panic("lastwords: already settled")
213	}
214	settled = true
215	if pot == 0 {
216		return
217	}
218
219	holder := author.String()
220	payees := []string{}
221	weights := []int64{}
222	for _, k := range order {
223		if k == holder {
224			continue
225		}
226		payees = append(payees, k)
227		weights = append(weights, totalPaid(k))
228	}
229	if len(payees) == 0 {
230		// The holder wrote every word there is. Nobody to share with.
231		payees = []string{holder}
232		weights = []int64{1}
233	}
234
235	shares, err := prorata.Split(pot, weights)
236	if err != nil {
237		panic("lastwords: " + err.Error())
238	}
239
240	// A zero share is dropped: CreditMany refuses a non-positive amount, and
241	// crediting nothing is not a payment anyway.
242	dst := []string{}
243	amounts := []int64{}
244	for i, s := range shares {
245		if s > 0 {
246			dst = append(dst, payees[i])
247			amounts = append(amounts, s)
248		}
249	}
250	if len(dst) > 0 {
251		if err := ledger.CreditMany(dst, amounts); err != nil {
252			panic("lastwords: " + err.Error())
253		}
254	}
255	pot = 0
256
257	chain.Emit("Settle",
258		"holder", holder,
259		"writes", strconv.FormatInt(writes, 10),
260		"payees", strconv.Itoa(len(dst)),
261	)
262}
263
264// Withdraw pays the caller everything credited to them and returns the amount.
265// Dividends are claimable while the game runs; settlement shares only after
266// Settle.
267func Withdraw(cur realm) int64 {
268	caller := cur.Previous().Address()
269	amount, err := ledger.Withdraw(caller.String())
270	if err != nil {
271		panic("lastwords: " + err.Error())
272	}
273	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
274	bnk.SendCoins(cur.Address(), caller, chain.NewCoins(chain.NewCoin(denom, amount)))
275
276	chain.Emit("Withdraw", "payee", caller.String(), "amount", strconv.FormatInt(amount, 10))
277	return amount
278}
279
280// Message returns the slot's current contents, raw. Render is what escapes it.
281func Message() string { return message }
282
283// Author returns who holds the slot.
284func Author() address { return author }
285
286// Pot returns what settlement will split.
287func Pot() int64 { return pot }
288
289// Deadline returns the height the slot closes at.
290func Deadline() int64 { return clk.Deadline() }
291
292// Owed returns what an address can Withdraw right now.
293func Owed(addr address) int64 { return ledger.Balance(addr.String()) }
294
295// totalPaid returns what an address has paid in total, zero if it never wrote.
296func totalPaid(key string) int64 {
297	v, ok := writers.Get(key).(int64)
298	if !ok {
299		return 0
300	}
301	return v
302}
303
304func gnot(ugnot int64) string {
305	whole := ugnot / 1000000
306	frac := ugnot % 1000000
307	s := strconv.FormatInt(whole, 10)
308	if frac == 0 {
309		return s
310	}
311	f := strconv.FormatInt(frac, 10)
312	for len(f) < 6 {
313		f = "0" + f
314	}
315	for len(f) > 1 && f[len(f)-1] == '0' {
316		f = f[:len(f)-1]
317	}
318	return s + "." + f
319}