markets.gno
18.21 Kb · 480 lines
1// Package markets keeps collateral, positions and winning payouts on Gno.
2// It does not discover external outcomes: each market trusts its pinned oracle.
3package markets
4
5import (
6 "chain"
7 "chain/banker"
8 "chain/runtime"
9 "chain/runtime/unsafe"
10 "crypto/sha256"
11 "encoding/hex"
12 "strconv"
13 "strings"
14 "time"
15 "unicode/utf8"
16)
17
18const scale int64 = 1000000
19const maxInt64 int64 = 9223372036854775807
20
21// All amounts are integer ugnot. One million micro-shares is one share;
22// a winning micro-share pays one ugnot (one whole share pays one GNOT).
23type Market struct {
24 ID string `json:"id"`
25 Question string `json:"question"`
26 Rules string `json:"rules"`
27 RulesHash string `json:"rulesHash"`
28 ConditionID string `json:"conditionId"`
29 YesTokenID string `json:"yesTokenId"`
30 NoTokenID string `json:"noTokenId"`
31 ClosesAt int64 `json:"closesAt"`
32 YesPricePpm int64 `json:"yesPricePpm"`
33 Oracle address `json:"oracle"`
34 EscrowUgnot int64 `json:"escrowUgnot"`
35 YesMicroShares int64 `json:"yesMicroShares"`
36 NoMicroShares int64 `json:"noMicroShares"`
37 Finalized bool `json:"finalized"`
38 Winner string `json:"winner"`
39 EvidenceHash string `json:"evidenceHash"`
40 FinalizedAt int64 `json:"finalizedAt"`
41 PaidUgnot int64 `json:"paidUgnot"`
42 QuoteVersion int64 `json:"quoteVersion"`
43 CreatedAt int64 `json:"createdAt"`
44 VolumeUgnot int64 `json:"volumeUgnot"`
45 Trades int64 `json:"trades"`
46}
47
48// QuotePoint records one published fixed quote. The history contains the
49// creation quote and every later administrator requote, in block-time order.
50type QuotePoint struct {
51 At int64 `json:"at"`
52 YesPricePpm int64 `json:"yesPricePpm"`
53 Version int64 `json:"version"`
54}
55
56type Position struct {
57 YesMicroShares int64 `json:"yesMicroShares"`
58 NoMicroShares int64 `json:"noMicroShares"`
59 SpentUgnot int64 `json:"spentUgnot"`
60 Claimed bool `json:"claimed"`
61 PaidUgnot int64 `json:"paidUgnot"`
62}
63
64type marketRecord struct {
65 market Market
66 positions map[address]Position
67 owners []address
68 history []QuotePoint
69}
70
71const maxHistory = 240
72
73// OriginCaller is used only at package initialization, to capture the deployer.
74// Entry-point authorization always uses the live immediate caller.
75var administrator = unsafe.OriginCaller()
76var pendingAdministrator address
77var records = make(map[string]*marketRecord)
78var marketIDs []string
79var totalEscrowUgnot int64
80
81func Administrator() address { return administrator }
82func PendingAdministrator() address { return pendingAdministrator }
83
84// ProposeAdministrator starts a two-step handover. The proposed address must
85// accept from its own direct user call, so a mistyped address cannot take over.
86// Each market keeps the oracle it was created with.
87func ProposeAdministrator(cur realm, next address) {
88 assertUser(0, cur)
89 assertNoSend()
90 require(cur.Previous().Address() == administrator, "administrator only")
91 require(next.IsValid() && next != administrator, "invalid administrator")
92 pendingAdministrator = next
93 chain.Emit("AdministratorProposed", "next", string(next))
94}
95
96// AcceptAdministrator completes the handover started by ProposeAdministrator.
97func AcceptAdministrator(cur realm) {
98 assertUser(0, cur)
99 assertNoSend()
100 caller := cur.Previous().Address()
101 require(pendingAdministrator != "" && caller == pendingAdministrator, "pending administrator only")
102 administrator = caller
103 pendingAdministrator = ""
104 chain.Emit("AdministratorAccepted", "administrator", string(caller))
105}
106
107// CreateMarket freezes every field used to match an external result.
108// There are deliberately no question/rules/source/oracle/price update functions.
109func CreateMarket(cur realm, id, question, rules, conditionID, yesTokenID, noTokenID string, closesAt, yesPricePpm int64, oracle address) {
110 assertUser(0, cur)
111 assertNoSend()
112 require(cur.Previous().Address() == administrator, "administrator only")
113 require(records[id] == nil, "market already exists")
114 require(id != "" && len(id) <= 64 && utf8.ValidString(id), "invalid market ID")
115 require(question != "" && len(question) <= 1024 && utf8.ValidString(question), "invalid question")
116 require(rules != "" && len(rules) <= 20000 && utf8.ValidString(rules), "invalid rules")
117 require(strings.HasPrefix(conditionID, "0x") && isHash(conditionID[2:]), "invalid condition ID")
118 require(isTokenID(yesTokenID) && isTokenID(noTokenID) && yesTokenID != noTokenID, "invalid outcome token order")
119 require(closesAt > time.Now().Unix(), "closing time must be in the future")
120 require(yesPricePpm > 0 && yesPricePpm < scale, "invalid fixed price")
121 require(oracle.IsValid(), "invalid oracle address")
122 now := time.Now().Unix()
123 m := Market{ID: id, Question: question, Rules: rules, RulesHash: BindingHash(question, rules), ConditionID: conditionID, YesTokenID: yesTokenID, NoTokenID: noTokenID, ClosesAt: closesAt, YesPricePpm: yesPricePpm, Oracle: oracle, QuoteVersion: 1, CreatedAt: now}
124 r := &marketRecord{market: m, positions: make(map[address]Position)}
125 r.history = append(r.history, QuotePoint{At: now, YesPricePpm: yesPricePpm, Version: 1})
126 records[id] = r
127 marketIDs = append(marketIDs, id)
128 chain.Emit("MarketCreated", "market", id, "yesPricePpm", decimal(yesPricePpm), "closesAt", decimal(closesAt))
129}
130
131// SetQuote publishes a new fixed quote while trading is open. expectedVersion
132// prevents an operator from overwriting a quote it has not seen. Every Buy
133// still checks the exact price its buyer reviewed.
134func SetQuote(cur realm, id string, yesPricePpm, expectedVersion int64) {
135 assertUser(0, cur)
136 assertNoSend()
137 require(cur.Previous().Address() == administrator, "administrator only")
138 r := mustMarket(id)
139 require(!r.market.Finalized && time.Now().Unix() < r.market.ClosesAt, "trading closed")
140 require(expectedVersion == r.market.QuoteVersion, "quote version changed")
141 require(yesPricePpm > 0 && yesPricePpm < scale, "invalid fixed price")
142 require(yesPricePpm != r.market.YesPricePpm, "quote unchanged")
143 r.market.YesPricePpm = yesPricePpm
144 r.market.QuoteVersion = checkedAdd(r.market.QuoteVersion, 1)
145 if len(r.history) >= maxHistory {
146 r.history = append([]QuotePoint{}, r.history[1:]...)
147 }
148 r.history = append(r.history, QuotePoint{At: time.Now().Unix(), YesPricePpm: yesPricePpm, Version: r.market.QuoteVersion})
149 chain.Emit("QuoteUpdated", "market", id, "yesPricePpm", decimal(yesPricePpm), "version", decimal(r.market.QuoteVersion))
150}
151
152// Fund adds non-refundable reserve capital, allocated solely to this market.
153// No withdrawal or banker capability is exposed, even to the administrator.
154func Fund(cur realm, id string) {
155 runtime.AssertOriginCall()
156 assertUser(0, cur)
157 r := mustMarket(id)
158 require(!r.market.Finalized && time.Now().Unix() < r.market.ClosesAt, "market funding closed")
159 amount := singleSend()
160 nextEscrow := checkedAdd(r.market.EscrowUgnot, amount)
161 nextTotal := checkedAdd(totalEscrowUgnot, amount)
162 assertBankCoverage(0, cur, nextTotal)
163 r.market.EscrowUgnot = nextEscrow
164 totalEscrowUgnot = nextTotal
165 chain.Emit("Funded", "market", id, "amountUgnot", decimal(amount))
166}
167
168// Buy creates positions only after exact payment and full market-specific
169// collateral coverage. expectedPricePpm is the selected outcome's captured price.
170func Buy(cur realm, id, outcome string, microShares, expectedPricePpm int64) {
171 runtime.AssertOriginCall()
172 assertUser(0, cur)
173 r := mustMarket(id)
174 m := r.market
175 require(!m.Finalized && time.Now().Unix() < m.ClosesAt, "trading closed")
176 price := outcomePrice(m, outcome)
177 require(expectedPricePpm == price, "price does not match review")
178 cost := quoteCost(microShares, price)
179 require(singleSend() == cost, "send the exact quoted ugnot amount")
180 nextYes, nextNo := m.YesMicroShares, m.NoMicroShares
181 if outcome == "Yes" {
182 nextYes = checkedAdd(nextYes, microShares)
183 } else {
184 nextNo = checkedAdd(nextNo, microShares)
185 }
186 nextEscrow := checkedAdd(m.EscrowUgnot, cost)
187 require(maximum(nextYes, nextNo) <= nextEscrow, "insufficient market collateral")
188 nextTotal := checkedAdd(totalEscrowUgnot, cost)
189 assertBankCoverage(0, cur, nextTotal)
190 owner := cur.Previous().Address()
191 p, held := r.positions[owner]
192 require(!p.Claimed, "position already claimed")
193 nextVolume := checkedAdd(m.VolumeUgnot, cost)
194 nextTrades := checkedAdd(m.Trades, 1)
195 if outcome == "Yes" {
196 p.YesMicroShares = checkedAdd(p.YesMicroShares, microShares)
197 } else {
198 p.NoMicroShares = checkedAdd(p.NoMicroShares, microShares)
199 }
200 p.SpentUgnot = checkedAdd(p.SpentUgnot, cost)
201 r.positions[owner] = p
202 if !held {
203 r.owners = append(r.owners, owner)
204 }
205 r.market.YesMicroShares, r.market.NoMicroShares = nextYes, nextNo
206 r.market.EscrowUgnot = nextEscrow
207 r.market.VolumeUgnot, r.market.Trades = nextVolume, nextTrades
208 totalEscrowUgnot = nextTotal
209 chain.Emit("Bought", "market", id, "buyer", string(owner), "outcome", outcome, "microShares", decimal(microShares), "costUgnot", decimal(cost), "pricePpm", decimal(price))
210}
211
212// FinalizeByOracle accepts only the pinned oracle and the entire immutable
213// source binding. EvidenceHash commits to an externally verified final record.
214// Neither a price threshold nor an unverified API response can call this by itself.
215// A source can resolve before the planned closing date. Oracle finalization
216// therefore closes trading immediately, without waiting for that deadline.
217func FinalizeByOracle(cur realm, id, conditionID, rulesHash, yesTokenID, noTokenID, winner, evidenceHash string) {
218 assertUser(0, cur)
219 assertNoSend()
220 r := mustMarket(id)
221 m := r.market
222 require(cur.Previous().Address() == m.Oracle, "oracle only")
223 require(!m.Finalized, "outcome already final")
224 require(conditionID == m.ConditionID && rulesHash == m.RulesHash && yesTokenID == m.YesTokenID && noTokenID == m.NoTokenID, "source binding mismatch")
225 require(winner == "Yes" || winner == "No", "invalid final outcome")
226 require(isHash(evidenceHash), "invalid evidence hash")
227 require(maximum(m.YesMicroShares, m.NoMicroShares) <= m.EscrowUgnot, "insufficient market collateral")
228 assertBankCoverage(0, cur, totalEscrowUgnot)
229 r.market.Finalized = true
230 r.market.Winner = winner
231 r.market.EvidenceHash = evidenceHash
232 r.market.FinalizedAt = time.Now().Unix()
233 chain.Emit("Finalized", "market", id, "winner", winner, "evidenceHash", evidenceHash)
234}
235
236// Claim pays only the immediate caller's winning shares. Repeated calls abort;
237// a losing position can be closed once with zero payout. A panic rolls back both
238// state and bank effects in the Gno transaction.
239func Claim(cur realm, id string) int64 {
240 assertUser(0, cur)
241 assertNoSend()
242 r := mustMarket(id)
243 m := r.market
244 require(m.Finalized, "outcome is not final")
245 owner := cur.Previous().Address()
246 p := r.positions[owner]
247 require(p.YesMicroShares > 0 || p.NoMicroShares > 0, "no position")
248 require(!p.Claimed, "position already claimed")
249 payout := p.YesMicroShares
250 if m.Winner == "No" {
251 payout = p.NoMicroShares
252 }
253 require(payout <= m.EscrowUgnot && payout <= totalEscrowUgnot, "insufficient market collateral")
254 assertBankCoverage(0, cur, totalEscrowUgnot)
255 paid := checkedAdd(m.PaidUgnot, payout)
256 p.Claimed, p.PaidUgnot = true, payout
257 r.positions[owner] = p
258 r.market.EscrowUgnot -= payout
259 r.market.PaidUgnot = paid
260 totalEscrowUgnot -= payout
261 if payout > 0 {
262 b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
263 b.SendCoins(cur.Address(), owner, chain.Coins{{Denom: "ugnot", Amount: payout}})
264 }
265 chain.Emit("Claimed", "market", id, "owner", string(owner), "payoutUgnot", decimal(payout))
266 return payout
267}
268
269func GetMarket(id string) Market { return mustMarket(id).market }
270func GetPosition(id string, owner address) Position { return mustMarket(id).positions[owner] }
271func Quote(id, outcome string, microShares int64) int64 {
272 return quoteCost(microShares, outcomePrice(mustMarket(id).market, outcome))
273}
274func ListMarkets() []Market {
275 result := make([]Market, len(marketIDs))
276 for i, id := range marketIDs {
277 result[i] = records[id].market
278 }
279 return result
280}
281
282// Gno 1.5.0 does not include encoding/json. These read-only projections use
283// explicit fields and JSON string escaping, without an external package.
284func MarketJSON(id string) string {
285 m := GetMarket(id)
286 return `{"id":` + jsonString(m.ID) + `,"question":` + jsonString(m.Question) + `,"rules":` + jsonString(m.Rules) +
287 `,"rulesHash":` + jsonString(m.RulesHash) + `,"conditionId":` + jsonString(m.ConditionID) +
288 `,"yesTokenId":` + jsonString(m.YesTokenID) + `,"noTokenId":` + jsonString(m.NoTokenID) +
289 `,"closesAt":` + decimal(m.ClosesAt) + `,"yesPricePpm":` + decimal(m.YesPricePpm) + `,"oracle":` + jsonString(string(m.Oracle)) +
290 `,"escrowUgnot":` + jsonString(decimal(m.EscrowUgnot)) + `,"yesMicroShares":` + jsonString(decimal(m.YesMicroShares)) + `,"noMicroShares":` + jsonString(decimal(m.NoMicroShares)) +
291 `,"finalized":` + strconv.FormatBool(m.Finalized) + `,"winner":` + jsonString(m.Winner) + `,"evidenceHash":` + jsonString(m.EvidenceHash) +
292 `,"finalizedAt":` + decimal(m.FinalizedAt) + `,"paidUgnot":` + jsonString(decimal(m.PaidUgnot)) +
293 `,"quoteVersion":` + decimal(m.QuoteVersion) + `,"createdAt":` + decimal(m.CreatedAt) +
294 `,"volumeUgnot":` + jsonString(decimal(m.VolumeUgnot)) + `,"trades":` + decimal(m.Trades) +
295 `,"holders":` + strconv.Itoa(len(mustMarket(id).owners)) +
296 `,"history":` + historyJSON(mustMarket(id).history) + `}`
297}
298
299// MarketsJSON returns every market in creation order as one JSON array.
300func MarketsJSON() string {
301 var result strings.Builder
302 result.WriteByte('[')
303 for i, id := range marketIDs {
304 if i > 0 {
305 result.WriteByte(',')
306 }
307 result.WriteString(MarketJSON(id))
308 }
309 result.WriteByte(']')
310 return result.String()
311}
312
313// PositionsJSON returns the owner's positions across all markets, in market
314// creation order. Markets where the owner never bought are omitted.
315func PositionsJSON(owner address) string {
316 var result strings.Builder
317 result.WriteByte('[')
318 first := true
319 for _, id := range marketIDs {
320 if _, ok := records[id].positions[owner]; !ok {
321 continue
322 }
323 if !first {
324 result.WriteByte(',')
325 }
326 first = false
327 result.WriteString(`{"marketId":` + jsonString(id) + `,"position":` + PositionJSON(id, owner) + `}`)
328 }
329 result.WriteByte(']')
330 return result.String()
331}
332
333func historyJSON(points []QuotePoint) string {
334 var result strings.Builder
335 result.WriteByte('[')
336 for i, point := range points {
337 if i > 0 {
338 result.WriteByte(',')
339 }
340 result.WriteString(`{"at":` + decimal(point.At) + `,"yesPricePpm":` + decimal(point.YesPricePpm) + `,"version":` + decimal(point.Version) + `}`)
341 }
342 result.WriteByte(']')
343 return result.String()
344}
345
346// QuoteHistory returns a detached copy of the published quotes.
347func QuoteHistory(id string) []QuotePoint {
348 points := mustMarket(id).history
349 result := make([]QuotePoint, len(points))
350 copy(result, points)
351 return result
352}
353
354// ppmPrice formats an integer in millionths with six fixed decimals.
355func ppmPrice(value int64) string {
356 whole := value / scale
357 fraction := strconv.FormatInt(value%scale, 10)
358 for len(fraction) < 6 {
359 fraction = "0" + fraction
360 }
361 return strconv.FormatInt(whole, 10) + "." + fraction
362}
363func PositionJSON(id string, owner address) string {
364 p := GetPosition(id, owner)
365 return `{"yesMicroShares":` + jsonString(decimal(p.YesMicroShares)) + `,"noMicroShares":` + jsonString(decimal(p.NoMicroShares)) +
366 `,"spentUgnot":` + jsonString(decimal(p.SpentUgnot)) + `,"claimed":` + strconv.FormatBool(p.Claimed) + `,"paidUgnot":` + jsonString(decimal(p.PaidUgnot)) + `}`
367}
368func decimal(value int64) string { return strconv.FormatInt(value, 10) }
369func jsonString(value string) string {
370 var result strings.Builder
371 result.WriteByte('"')
372 for _, character := range value {
373 switch character {
374 case '"', '\\':
375 result.WriteByte('\\')
376 result.WriteRune(character)
377 case '\b':
378 result.WriteString(`\b`)
379 case '\f':
380 result.WriteString(`\f`)
381 case '\n':
382 result.WriteString(`\n`)
383 case '\r':
384 result.WriteString(`\r`)
385 case '\t':
386 result.WriteString(`\t`)
387 default:
388 if character < 32 {
389 const digits = "0123456789abcdef"
390 result.WriteString(`\u00`)
391 result.WriteByte(digits[byte(character)/16])
392 result.WriteByte(digits[byte(character)%16])
393 } else {
394 result.WriteRune(character)
395 }
396 }
397 }
398 result.WriteByte('"')
399 return result.String()
400}
401
402// Hash UTF-8 length-framed text, shared exactly with the backend source binding.
403func BindingHash(question, rules string) string {
404 framed := strconv.FormatInt(int64(len(question)), 10) + ":" + question + strconv.FormatInt(int64(len(rules)), 10) + ":" + rules
405 sum := sha256.Sum256([]byte(framed))
406 return hex.EncodeToString(sum[:])
407}
408
409func assertUser(_ int, context realm) {
410 require(context.IsCurrent(), "invalid current realm")
411 require(context.Previous().IsUserCall(), "direct user call required")
412}
413func assertNoSend() { require(len(unsafe.OriginSend()) == 0, "this action does not accept coins") }
414func singleSend() int64 {
415 sent := unsafe.OriginSend()
416 require(len(sent) == 1 && sent[0].Denom == "ugnot" && sent[0].Amount > 0, "send positive ugnot only")
417 return sent[0].Amount
418}
419func assertBankCoverage(_ int, context realm, required int64) {
420 require(banker.NewReadonlyBanker().GetCoin(context.Address(), "ugnot") >= required, "payment not credited to realm")
421}
422func mustMarket(id string) *marketRecord {
423 r := records[id]
424 require(r != nil, "unknown market")
425 return r
426}
427func outcomePrice(m Market, outcome string) int64 {
428 require(outcome == "Yes" || outcome == "No", "invalid outcome")
429 if outcome == "Yes" {
430 return m.YesPricePpm
431 }
432 return scale - m.YesPricePpm
433}
434func quoteCost(microShares, price int64) int64 {
435 require(microShares > 0 && price > 0 && price < scale, "invalid quote amount")
436 require(microShares <= maxInt64/price, "quote overflow")
437 product := microShares * price
438 cost := product / scale
439 if product%scale != 0 {
440 cost = checkedAdd(cost, 1)
441 }
442 return cost
443}
444func checkedAdd(a, b int64) int64 {
445 require(a >= 0 && b >= 0 && a <= maxInt64-b, "amount overflow")
446 return a + b
447}
448func maximum(a, b int64) int64 {
449 if a > b {
450 return a
451 }
452 return b
453}
454func require(ok bool, message string) {
455 if !ok {
456 panic(message)
457 }
458}
459func isHash(s string) bool {
460 if len(s) != 64 {
461 return false
462 }
463 for _, c := range s {
464 if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
465 return false
466 }
467 }
468 return true
469}
470func isTokenID(s string) bool {
471 if len(s) == 0 || len(s) > 78 || len(s) > 1 && s[0] == '0' {
472 return false
473 }
474 for _, c := range s {
475 if c < '0' || c > '9' {
476 return false
477 }
478 }
479 return true
480}