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

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}