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

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}