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}