curated.gno
10.68 Kb · 265 lines
1// Package curated is a list anyone can get onto by locking a deposit, and
2// anyone can try to get somebody else off by matching that deposit with a bond.
3// The loser of the challenge pays the winner.
4//
5// It is the registry member of a family of small social apps: a list is the
6// thing every other one of them eventually needs, and a list is only worth
7// reading if being on it cost something.
8//
9// Apply(key, url, description) -send 1000000ugnot list it, immediately
10// Challenge(key) -send <the deposit> object to it
11// Vote(key, keep) while the window is open
12// Resolve(key) anyone, after it closes
13// Unlist(key) the owner, if unchallenged
14// Withdraw() collect what you won
15//
16// # Money in, money out
17//
18// Coins arrive in the envelope of a call and sit at this realm's address. They
19// never leave by being pushed: a payout is a credit in a ledger and the payee
20// calls [Withdraw] for their own. A realm that looped over winners and sent to
21// each one would fail entirely when one of them could not be paid, and would
22// hand a griefer a cheap denial of service for the price of one entry.
23//
24// Every ugnot here is therefore either backing a live entry or an open
25// challenge, or already assigned to somebody. The realm's own tests assert
26// exactly that against the chain balance.
27//
28// # Voting is sybil-prone, and the bonds are GNOT
29//
30// [Vote] is one address, one vote, unweighted, and an address is free. Read a
31// resolution as "nobody with a stake objected enough", never as a verdict.
32// Gating a vote on something harder to manufacture is a different problem with
33// its own realm behind it, r/moul/x/social/vouch, a sibling in this family.
34//
35// Bonds are GNOT and not a token of this list's own: see the README on why
36// that is the only answer available at v0 and what would change it.
37//
38// # What v0 does not do, in the order it should be fixed
39//
40// 1. Voters are paid nothing. Voting costs gas and returns nothing, so the
41// only two addresses with a reason to vote are the ones with money on the
42// outcome. A share of the loser's stake for the winning side is the
43// standard answer and the first thing to add.
44// 2. There is no application period: [Apply] lists immediately.
45// 3. A challenge cannot be withdrawn and a vote cannot be changed.
46package curated
47
48import (
49 "chain"
50 "chain/banker"
51 "chain/runtime"
52 "strconv"
53
54 "gno.land/p/moul/x/envelope/v0"
55 cu "gno.land/p/moul/x/social/curated/v0"
56)
57
58// realmPath is this realm's own path, the one its gnomod.toml module line
59// declares. Render builds its links from it rather than from
60// unsafe.CurrentRealm(), which in a plain read reports the CALLER.
61const realmPath = "gno.land/r/moul/x/social/curated/v0"
62
63const (
64 // Denom is what a deposit and a bond are paid in. Native coins, so the
65 // amounts are real to a challenger before this list is worth anything.
66 Denom = "ugnot"
67
68 // Deposit is what listing costs, and therefore also what challenging one
69 // of today's entries costs. One GNOT: enough to make a thousand junk
70 // entries a real expense, cheap enough that one honest entry is not a
71 // decision.
72 Deposit = int64(1000000)
73
74 // ChallengeBlocks is how long a challenge takes to resolve. Long enough
75 // for a reader who is not watching the chain to notice and vote, short
76 // enough that an entry is not held hostage.
77 ChallengeBlocks = int64(1000)
78)
79
80// list is every entry and the credit ledger behind them. A redeploy would wipe
81// it while leaving the coins at the address, which is what the note in
82// gnomod.toml is about.
83var list = cu.New(Deposit, ChallengeBlocks)
84
85// Apply lists an entry under key, in exchange for exactly [Deposit] ugnot.
86//
87// The entry is on the list the moment this returns: there is no application
88// period in v0, and the check on a bad entry is that anybody can challenge it.
89// The deposit comes back through [Unlist] and [Withdraw] if nobody ever does.
90//
91// key is a slug: lowercase ASCII letters, digits, '-', '_' and '.', starting
92// alphanumeric. A key whose entry was removed is free to apply for again.
93func Apply(cur realm, key, url, description string) {
94 // Both checks are inlined rather than hidden behind caller(), and in this
95 // order. cur.IsCurrent() before cur.Previous() is the realm-token rule;
96 // IsUserCall before the envelope read is the payment one, because the
97 // envelope reports what the SIGNER attached to the transaction and not
98 // what reached this realm. Without it a realm the user called keeps the
99 // coins and calls in here as often as it likes, every call reading the
100 // same send: that is r/moul/grant Fund, which recorded 5 donations of
101 // 1 GNOT from one 1 GNOT send with nothing in the treasury.
102 if !cur.IsCurrent() {
103 panic("spoofed realm: cur is not the live crossing frame")
104 }
105 if !cur.Previous().IsUserCall() {
106 panic("curated: paying in must be a direct user transaction")
107 }
108 who := cur.Previous().Address()
109 paid := envelope.RequireExactly(Denom, Deposit)
110 if err := list.Apply(key, url, description, who, paid, runtime.ChainHeight()); err != nil {
111 panic(err.Error())
112 }
113 chain.Emit("Apply", "key", key, "owner", who.String(),
114 "deposit", strconv.FormatInt(paid, 10))
115}
116
117// Challenge objects to an entry, in exchange for a bond equal to that entry's
118// own deposit, and opens a vote that closes [ChallengeBlocks] blocks later.
119//
120// An owner cannot challenge their own entry: it would cost them nothing and
121// would make the entry immune to a real challenge for the whole window.
122func Challenge(cur realm, key string) {
123 // The two guards every payable call needs, inlined; see Apply.
124 if !cur.IsCurrent() {
125 panic("spoofed realm: cur is not the live crossing frame")
126 }
127 if !cur.Previous().IsUserCall() {
128 panic("curated: paying in must be a direct user transaction")
129 }
130 who := cur.Previous().Address()
131 // The bond is read before the envelope is, so somebody who attaches coins
132 // to a challenge of something unchallengeable is told which of the two is
133 // wrong.
134 bond, ok := list.BondFor(key)
135 if !ok {
136 panic("curated: " + key + " is not an entry that can be challenged")
137 }
138 envelope.RequireExactly(Denom, bond)
139 if err := list.Challenge(key, who, bond, runtime.ChainHeight()); err != nil {
140 panic(err.Error())
141 }
142 c, _ := list.ChallengeOf(key)
143 chain.Emit("Challenge", "key", key, "by", who.String(),
144 "bond", strconv.FormatInt(bond, 10),
145 "deadline", strconv.FormatInt(c.Deadline, 10))
146}
147
148// Vote takes a side on an open challenge: keep the entry, or remove it.
149//
150// One address, one vote, unweighted, and it cannot be changed. An address is
151// free, so this is cheap to manufacture; see the package doc.
152func Vote(cur realm, key string, keep bool) {
153 who := caller(cur)
154 if err := list.Vote(key, who, keep, runtime.ChainHeight()); err != nil {
155 panic(err.Error())
156 }
157 chain.Emit("Vote", "key", key, "by", who.String(), "keep", strconv.FormatBool(keep))
158}
159
160// Resolve closes a challenge whose window has passed. Anyone may call it, so
161// neither party can stall the other by sitting still.
162//
163// A majority of keep votes keeps the entry and credits its owner the bond.
164// Otherwise the entry is removed and the challenger is credited the bond plus
165// the deposit. A tie, including nobody voting at all, keeps the entry: the
166// incumbent is the one already at risk, so a challenge that convinced nobody
167// loses, which is what makes being wrong cost something.
168func Resolve(cur realm, key string) {
169 who := caller(cur)
170 out, err := list.Resolve(key, runtime.ChainHeight())
171 if err != nil {
172 panic(err.Error())
173 }
174 chain.Emit("Resolve", "key", key,
175 "kept", strconv.FormatBool(out.Kept),
176 "winner", out.Winner.String(),
177 "amount", strconv.FormatInt(out.Amount, 10),
178 "keep", strconv.FormatInt(out.Keep, 10),
179 "remove", strconv.FormatInt(out.Remove, 10),
180 "by", who.String())
181}
182
183// Unlist takes the caller's own entry off the list and credits them the
184// deposit back, which [Withdraw] then pays out.
185//
186// It is refused while a challenge is open: an owner who could walk away
187// mid-challenge would be risking nothing, which is the one thing the deposit
188// exists to prevent.
189func Unlist(cur realm, key string) {
190 who := caller(cur)
191 if err := list.Unlist(key, who); err != nil {
192 panic(err.Error())
193 }
194 chain.Emit("Unlist", "key", key, "owner", who.String())
195}
196
197// Withdraw pays the caller everything credited to them and returns it.
198//
199// This is the only way coins leave the realm. The credit is zeroed before the
200// transfer, so a reentrant call finds nothing left to take.
201func Withdraw(cur realm) int64 {
202 who := caller(cur)
203 amount, err := list.Withdraw(who)
204 if err != nil {
205 panic(err.Error())
206 }
207 bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
208 bnk.SendCoins(cur.Address(), who, chain.NewCoins(chain.NewCoin(Denom, amount)))
209
210 chain.Emit("Withdraw", "to", who.String(), "amount", strconv.FormatInt(amount, 10))
211 return amount
212}
213
214// Get returns what is known about an entry: its URL, its description, its
215// owner, the deposit behind it, the height it was listed at, and its state,
216// which is one of "listed", "challenged" or "removed".
217//
218// A key that was never applied for reads as the zero value with an empty
219// state, which is how a caller tells it from a removed one.
220func Get(key string) (url, description string, owner address, deposit, at int64, state string) {
221 e, ok := list.Get(key)
222 if !ok {
223 return "", "", "", 0, 0, ""
224 }
225 return e.URL, e.Description, e.Owner, e.Deposit, e.At, e.State.String()
226}
227
228// Count is how many entries are on the list right now. A challenged entry
229// counts: a challenge is an objection, not a verdict.
230func Count() int { return list.Count() }
231
232// Listed is every key on the list, oldest first.
233func Listed() []string {
234 entries := list.Listed()
235 out := make([]string, 0, len(entries))
236 for _, e := range entries {
237 out = append(out, e.Key)
238 }
239 return out
240}
241
242// IsListed reports whether key is on the list right now.
243func IsListed(key string) bool { return list.IsListed(key) }
244
245// ChallengeOf returns the open challenge against key: who opened it, the bond
246// they put up, the height voting closes at, the two vote counts, and whether
247// there is one at all.
248func ChallengeOf(key string) (challenger address, bond, deadline, keep, remove int64, open bool) {
249 c, ok := list.ChallengeOf(key)
250 if !ok {
251 return "", 0, 0, 0, 0, false
252 }
253 return c.Challenger, c.Bond, c.Deadline, c.Keep, c.Remove, true
254}
255
256// CreditOf is what who can collect with [Withdraw] right now.
257func CreditOf(who address) int64 { return list.CreditOf(who) }
258
259// caller is the address that called us, checked the one way that is safe.
260func caller(cur realm) address {
261 if !cur.IsCurrent() {
262 panic("spoofed realm: cur is not the live crossing frame")
263 }
264 return cur.Previous().Address()
265}