markets.gno
33.38 Kb · 834 lines
1// Package markets is realm v2 of 67: fully collateralized markets on Gno with a
2// service fee, two-sided expiring quotes, limits, pauses, a resolution window,
3// void refunds and an abandonment exit. It does not discover outcomes: each
4// market trusts its pinned oracle, and a guardian can cancel a proposal for 24
5// hours before anyone can make it final.
6//
7// Money only leaves through Claim (to the holder who calls) and WithdrawSurplus
8// (to the market's fixed treasury, after it is final). Nothing can pause them.
9package markets
10
11import (
12 "chain"
13 "chain/banker"
14 "chain/runtime"
15 "chain/runtime/unsafe"
16 "crypto/sha256"
17 "encoding/hex"
18 "math/bits"
19 "strconv"
20 "strings"
21 "time"
22 "unicode/utf8"
23)
24
25const (
26 scale int64 = 1000000
27 maxInt64 int64 = 9223372036854775807
28
29 // MaxFeePpm caps every market's service fee rate θ at 0.07. A rate is fixed
30 // when a market is listed and can never change.
31 MaxFeePpm int64 = 70000
32 // Each side's price stays inside 0.03 to 0.97; outside it the quoter pauses.
33 MinPricePpm int64 = 30000
34 MaxPricePpm int64 = 970000
35 // DisputeWindow is how long a proposed outcome waits before anyone can make
36 // it final; the guardian can cancel it in that time.
37 DisputeWindow int64 = 24 * 60 * 60
38 // AbandonAfter: if no outcome is final this long after trading ends, anyone
39 // can void the market and every holder takes a refund.
40 AbandonAfter int64 = 60 * 24 * 60 * 60
41
42 minQuoteTTL int64 = 5 * 60
43 maxQuoteTTL int64 = 7 * 24 * 60 * 60
44 maxMinSpread int64 = 100000
45 maxHistory = 96
46)
47
48// Market states. A market moves between open and paused, from either to
49// proposed, and from proposed to final. A cancelled proposal returns it to
50// paused; abandonment takes an open or paused market to final (Void).
51const (
52 StateOpen = "open"
53 StatePaused = "paused"
54 StateProposed = "proposed"
55 StateFinal = "final"
56)
57
58// All amounts are integer ugnot. One million micro-shares is one share; a
59// winning micro-share pays one ugnot (one whole share pays one GNOT).
60type Market struct {
61 ID string `json:"id"`
62 Question string `json:"question"`
63 Rules string `json:"rules"`
64 RulesHash string `json:"rulesHash"`
65 ConditionID string `json:"conditionId"`
66 YesTokenID string `json:"yesTokenId"`
67 NoTokenID string `json:"noTokenId"`
68 // The outcomes' names as Polymarket shows them, in token order: "Yes" and
69 // "No", or two teams. Yes and No below always mean the first and second token.
70 YesLabel string `json:"yesLabel"`
71 NoLabel string `json:"noLabel"`
72 Oracle address `json:"oracle"`
73 Treasury address `json:"treasury"`
74 Topic string `json:"topic"` // the 67 topic it is shown under, fixed at listing
75 FeePpm int64 `json:"feePpm"`
76 CreatedAt int64 `json:"createdAt"`
77 // TradingEndsAt is set at listing, before the source event.
78 TradingEndsAt int64 `json:"tradingEndsAt"`
79 AbandonAt int64 `json:"abandonAt"`
80
81 YesPricePpm int64 `json:"yesPricePpm"`
82 NoPricePpm int64 `json:"noPricePpm"`
83 QuoteVersion int64 `json:"quoteVersion"`
84 QuotedAt int64 `json:"quotedAt"`
85
86 State string `json:"state"`
87 Outcome string `json:"outcome"` // proposed or final: Yes, No or Void
88 EvidenceHash string `json:"evidenceHash"`
89 ProposedAt int64 `json:"proposedAt"`
90 FinalizedAt int64 `json:"finalizedAt"`
91
92 // EscrowUgnot is the collateral: reserve plus purchase costs, less payouts,
93 // refunds and withdrawn surplus. FeesUgnot is held apart from it.
94 EscrowUgnot int64 `json:"escrowUgnot"`
95 FeesUgnot int64 `json:"feesUgnot"`
96 ReserveUgnot int64 `json:"reserveUgnot"`
97 CostUgnot int64 `json:"costUgnot"`
98 FeesCollectedUgnot int64 `json:"feesCollectedUgnot"`
99 YesMicroShares int64 `json:"yesMicroShares"`
100 NoMicroShares int64 `json:"noMicroShares"`
101 PaidUgnot int64 `json:"paidUgnot"`
102 RefundedCostUgnot int64 `json:"refundedCostUgnot"`
103 RefundedFeesUgnot int64 `json:"refundedFeesUgnot"`
104 WithdrawnUgnot int64 `json:"withdrawnUgnot"`
105
106 MaxTradeMicroShares int64 `json:"maxTradeMicroShares"`
107 MaxHolderMicroShares int64 `json:"maxHolderMicroShares"`
108 MaxSideMicroShares int64 `json:"maxSideMicroShares"`
109
110 Holders int64 `json:"holders"` // positions not yet settled
111 VolumeUgnot int64 `json:"volumeUgnot"`
112 Trades int64 `json:"trades"`
113}
114
115// QuotePoint records one published quote, in block-time order.
116type QuotePoint struct {
117 At int64 `json:"at"`
118 YesPricePpm int64 `json:"yesPricePpm"`
119 NoPricePpm int64 `json:"noPricePpm"`
120 Version int64 `json:"version"`
121}
122
123// Position is an open holding. It is deleted when its holder claims, so the
124// storage it used is released; the Claimed event keeps the record.
125type Position struct {
126 YesMicroShares int64 `json:"yesMicroShares"`
127 NoMicroShares int64 `json:"noMicroShares"`
128 SpentUgnot int64 `json:"spentUgnot"`
129 FeeUgnot int64 `json:"feeUgnot"`
130}
131
132type marketRecord struct {
133 market Market
134 positions map[address]Position
135 history []QuotePoint
136}
137
138// Limits bound new purchases. They never affect claims.
139type Limits struct {
140 MaxTradeMicroShares int64 `json:"maxTradeMicroShares"`
141 MaxHolderMicroShares int64 `json:"maxHolderMicroShares"`
142 MaxSideMicroShares int64 `json:"maxSideMicroShares"`
143}
144
145// OriginCaller is used only at package initialization, to capture the deployer
146// as the first holder of every role. Entry points use the live caller.
147var (
148 administrator = unsafe.OriginCaller()
149 pendingAdministrator address
150 quoter = unsafe.OriginCaller()
151 pendingQuoter address
152 guardian = unsafe.OriginCaller()
153 pendingGuardian address
154 lister address
155 pendingLister address
156
157 // The terms a lister must list with; only the administrator sets them.
158 listingOracle address
159 listingTreasury address
160 listingFeePpm int64
161
162 realmPaused bool
163 quoteTTL int64 = 60 * 60
164 minSpreadPpm int64
165 maxExposure int64 = 200000 * scale // micro-shares owed if every open market ends on its larger side
166 openExposure int64
167 defaultLimits = Limits{MaxTradeMicroShares: 500 * scale, MaxHolderMicroShares: 2000 * scale, MaxSideMicroShares: 20000 * scale}
168 records = make(map[string]*marketRecord)
169 marketIDs []string
170 totalHeldUgnot int64 // every market's escrow plus fees still held
171)
172
173// ----- Roles -----
174
175func Administrator() address { return administrator }
176func Quoter() address { return quoter }
177func Guardian() address { return guardian }
178func Lister() address { return lister }
179
180// ProposeRole starts a two-step handover of "admin", "quoter", "guardian" or
181// "lister".
182// The proposed address accepts from its own direct call, so a mistyped
183// address cannot take a role.
184func ProposeRole(cur realm, role string, next address) {
185 assertOwnKey(0, cur)
186 require(caller(0, cur) == administrator, "administrator only")
187 require(next.IsValid(), "invalid address")
188 switch role {
189 case "admin":
190 require(next != administrator, "already the administrator")
191 pendingAdministrator = next
192 case "quoter":
193 pendingQuoter = next
194 case "guardian":
195 pendingGuardian = next
196 case "lister":
197 pendingLister = next
198 default:
199 panic("unknown role")
200 }
201 chain.Emit("RoleProposed", "role", role, "next", string(next))
202}
203
204// AcceptRole completes a handover started by ProposeRole.
205func AcceptRole(cur realm, role string) {
206 assertOwnKey(0, cur)
207 who := caller(0, cur)
208 switch role {
209 case "admin":
210 require(pendingAdministrator != "" && who == pendingAdministrator, "pending administrator only")
211 administrator, pendingAdministrator = who, ""
212 case "quoter":
213 require(pendingQuoter != "" && who == pendingQuoter, "pending quoter only")
214 quoter, pendingQuoter = who, ""
215 case "guardian":
216 require(pendingGuardian != "" && who == pendingGuardian, "pending guardian only")
217 guardian, pendingGuardian = who, ""
218 case "lister":
219 require(pendingLister != "" && who == pendingLister, "pending lister only")
220 lister, pendingLister = who, ""
221 default:
222 panic("unknown role")
223 }
224 chain.Emit("RoleAccepted", "role", role, "holder", string(who))
225}
226
227// RevokeRole empties the quoter, guardian or lister role at once, for a lost or
228// stolen key. The administrator role can only be handed over.
229func RevokeRole(cur realm, role string) {
230 assertOwnKey(0, cur)
231 require(caller(0, cur) == administrator, "administrator only")
232 switch role {
233 case "quoter":
234 quoter, pendingQuoter = "", ""
235 case "guardian":
236 guardian, pendingGuardian = "", ""
237 case "lister":
238 lister, pendingLister = "", ""
239 default:
240 panic("only the quoter, guardian or lister can be revoked")
241 }
242 chain.Emit("RoleRevoked", "role", role)
243}
244
245// ----- Settings -----
246
247// SetSettings changes the quote lifetime, the minimum spread and the realm-wide
248// exposure cap. They apply to new quotes and purchases only.
249func SetSettings(cur realm, quoteTTLSeconds, minimumSpreadPpm, maxExposureMicroShares int64) {
250 assertOwnKey(0, cur)
251 require(caller(0, cur) == administrator, "administrator only")
252 require(quoteTTLSeconds >= minQuoteTTL && quoteTTLSeconds <= maxQuoteTTL, "quote lifetime out of range")
253 require(minimumSpreadPpm >= 0 && minimumSpreadPpm <= maxMinSpread, "spread out of range")
254 require(maxExposureMicroShares > 0, "invalid exposure cap")
255 quoteTTL, minSpreadPpm, maxExposure = quoteTTLSeconds, minimumSpreadPpm, maxExposureMicroShares
256 chain.Emit("SettingsChanged", "quoteTTL", decimal(quoteTTL), "minSpreadPpm", decimal(minSpreadPpm), "maxExposureMicroShares", decimal(maxExposure))
257}
258
259// SetLimits changes one market's purchase limits, or with id "" the limits new
260// markets start with.
261func SetLimits(cur realm, id string, maxTradeMicroShares, maxHolderMicroShares, maxSideMicroShares int64) {
262 assertOwnKey(0, cur)
263 require(caller(0, cur) == administrator, "administrator only")
264 require(maxTradeMicroShares > 0 && maxHolderMicroShares > 0 && maxSideMicroShares > 0, "invalid limits")
265 require(maxTradeMicroShares <= maxSideMicroShares, "a trade cannot exceed the market limit")
266 l := Limits{MaxTradeMicroShares: maxTradeMicroShares, MaxHolderMicroShares: maxHolderMicroShares, MaxSideMicroShares: maxSideMicroShares}
267 if id == "" {
268 defaultLimits = l
269 } else {
270 r := mustMarket(id)
271 require(r.market.State != StateFinal, "market is final")
272 r.market.MaxTradeMicroShares, r.market.MaxHolderMicroShares, r.market.MaxSideMicroShares = l.MaxTradeMicroShares, l.MaxHolderMicroShares, l.MaxSideMicroShares
273 }
274 chain.Emit("LimitsChanged", "market", id, "maxTradeMicroShares", decimal(maxTradeMicroShares), "maxHolderMicroShares", decimal(maxHolderMicroShares), "maxSideMicroShares", decimal(maxSideMicroShares))
275}
276
277// ----- Listing and quotes -----
278
279// SetListingTerms fixes the oracle, treasury and fee rate every market listed
280// by the lister must carry. A lister key can then only add markets on these
281// terms; it can never choose who settles them or where their surplus goes.
282func SetListingTerms(cur realm, oracle, treasury address, feePpm int64) {
283 assertOwnKey(0, cur)
284 require(caller(0, cur) == administrator, "administrator only")
285 require(oracle.IsValid() && treasury.IsValid(), "invalid address")
286 require(oracle != administrator && oracle != quoter && oracle != lister, "the oracle must be a separate account")
287 require(feePpm >= 0 && feePpm <= MaxFeePpm, "fee rate above the cap")
288 listingOracle, listingTreasury, listingFeePpm = oracle, treasury, feePpm
289 chain.Emit("ListingTermsChanged", "oracle", string(oracle), "treasury", string(treasury), "feePpm", decimal(feePpm))
290}
291
292// CreateMarket freezes every field used to match an external result, the fee
293// rate, the oracle and the treasury. Nothing but the quote and the limits can
294// change later. The administrator chooses the terms; the lister must use the
295// listing terms.
296func CreateMarket(cur realm, id, question, rules, conditionID, yesTokenID, noTokenID, yesLabel, noLabel string, tradingEndsAt, yesPricePpm, noPricePpm, feePpm int64, oracle, treasury address, topic string) {
297 assertOwnKey(0, cur)
298 who := caller(0, cur)
299 require(who == administrator || who != "" && who == lister, "administrator or lister only")
300 if who != administrator {
301 require(listingOracle != "" && oracle == listingOracle && treasury == listingTreasury && feePpm == listingFeePpm, "the lister must use the listing terms")
302 }
303 require(records[id] == nil, "market already exists")
304 require(id != "" && len(id) <= 64 && isSlug(id), "invalid market ID")
305 require(question != "" && len(question) <= 1024 && utf8.ValidString(question), "invalid question")
306 require(rules != "" && len(rules) <= 20000 && utf8.ValidString(rules), "invalid rules")
307 require(strings.HasPrefix(conditionID, "0x") && isHash(conditionID[2:]), "invalid condition ID")
308 require(isTokenID(yesTokenID) && isTokenID(noTokenID) && yesTokenID != noTokenID, "invalid outcome token order")
309 require(isLabel(yesLabel) && isLabel(noLabel) && yesLabel != noLabel, "invalid outcome names")
310 now := time.Now().Unix()
311 require(tradingEndsAt > now, "trading must end in the future")
312 require(tradingEndsAt <= maxInt64-AbandonAfter, "invalid trading end")
313 checkQuote(yesPricePpm, noPricePpm)
314 require(feePpm >= 0 && feePpm <= MaxFeePpm, "fee rate above the cap")
315 require(oracle.IsValid(), "invalid oracle address")
316 require(oracle != administrator && oracle != quoter && oracle != lister, "the oracle must be a separate account")
317 require(treasury.IsValid(), "invalid treasury address")
318 require(isTopic(topic), "invalid topic")
319 m := Market{ID: id, Question: question, Rules: rules, RulesHash: BindingHash(question, rules), ConditionID: conditionID,
320 YesTokenID: yesTokenID, NoTokenID: noTokenID, YesLabel: yesLabel, NoLabel: noLabel, Oracle: oracle, Treasury: treasury, Topic: topic, FeePpm: feePpm, CreatedAt: now,
321 TradingEndsAt: tradingEndsAt, AbandonAt: tradingEndsAt + AbandonAfter,
322 YesPricePpm: yesPricePpm, NoPricePpm: noPricePpm, QuoteVersion: 1, QuotedAt: now, State: StateOpen,
323 MaxTradeMicroShares: defaultLimits.MaxTradeMicroShares, MaxHolderMicroShares: defaultLimits.MaxHolderMicroShares, MaxSideMicroShares: defaultLimits.MaxSideMicroShares}
324 r := &marketRecord{market: m, positions: make(map[address]Position)}
325 r.history = append(r.history, QuotePoint{At: now, YesPricePpm: yesPricePpm, NoPricePpm: noPricePpm, Version: 1})
326 records[id] = r
327 marketIDs = append(marketIDs, id)
328 chain.Emit("MarketCreated", "market", id, "yesPricePpm", decimal(yesPricePpm), "noPricePpm", decimal(noPricePpm),
329 "feePpm", decimal(feePpm), "tradingEndsAt", decimal(tradingEndsAt), "oracle", string(oracle), "treasury", string(treasury), "topic", topic,
330 "yesLabel", yesLabel, "noLabel", noLabel)
331}
332
333// SetQuote publishes both prices while trading is open or paused. The quoter
334// may sign it with a session key. expectedVersion refuses a quote made from a
335// stale view. The same prices again only renew the quote's time, so the keeper
336// can keep an unchanged quote from expiring without a new version.
337func SetQuote(cur realm, id string, yesPricePpm, noPricePpm, expectedVersion int64) {
338 assertUser(0, cur)
339 assertNoSend()
340 who := caller(0, cur)
341 require(who == quoter || who == administrator, "quoter only")
342 r := mustMarket(id)
343 m := r.market
344 require(m.State == StateOpen || m.State == StatePaused, "trading closed")
345 now := time.Now().Unix()
346 require(now < m.TradingEndsAt, "trading closed")
347 require(expectedVersion == m.QuoteVersion, "quote version changed")
348 checkQuote(yesPricePpm, noPricePpm)
349 r.market.QuotedAt = now
350 if yesPricePpm == m.YesPricePpm && noPricePpm == m.NoPricePpm {
351 chain.Emit("QuoteRenewed", "market", id, "version", decimal(m.QuoteVersion))
352 return
353 }
354 r.market.YesPricePpm, r.market.NoPricePpm = yesPricePpm, noPricePpm
355 r.market.QuoteVersion = checkedAdd(m.QuoteVersion, 1)
356 if len(r.history) >= maxHistory {
357 r.history = append([]QuotePoint{}, r.history[1:]...)
358 }
359 r.history = append(r.history, QuotePoint{At: now, YesPricePpm: yesPricePpm, NoPricePpm: noPricePpm, Version: r.market.QuoteVersion})
360 chain.Emit("QuoteUpdated", "market", id, "yesPricePpm", decimal(yesPricePpm), "noPricePpm", decimal(noPricePpm), "version", decimal(r.market.QuoteVersion))
361}
362
363// Pause stops new purchases in one market, or with id "" in every market. The
364// quoter (also with a session key), the guardian and the administrator can
365// pause; only the administrator resumes. Claims are never paused.
366func Pause(cur realm, id string) {
367 assertUser(0, cur)
368 assertNoSend()
369 who := caller(0, cur)
370 require(who == quoter || who == guardian || who == administrator, "quoter, guardian or administrator only")
371 if id == "" {
372 require(!realmPaused, "already paused")
373 realmPaused = true
374 } else {
375 r := mustMarket(id)
376 require(r.market.State == StateOpen, "market is not open")
377 r.market.State = StatePaused
378 }
379 chain.Emit("Paused", "market", id, "by", string(who))
380}
381
382// Resume reopens one paused market, or with id "" lifts the realm-wide pause.
383func Resume(cur realm, id string) {
384 assertOwnKey(0, cur)
385 require(caller(0, cur) == administrator, "administrator only")
386 if id == "" {
387 require(realmPaused, "not paused")
388 realmPaused = false
389 } else {
390 r := mustMarket(id)
391 require(r.market.State == StatePaused, "market is not paused")
392 r.market.State = StateOpen
393 }
394 chain.Emit("Resumed", "market", id)
395}
396
397// ----- Money in -----
398
399// Fund adds reserve capital to one market. It backs that market's sales and
400// returns to the market's treasury only after the market is final.
401func Fund(cur realm, id string) {
402 runtime.AssertOriginCall()
403 assertUser(0, cur)
404 r := mustMarket(id)
405 require(r.market.State == StateOpen || r.market.State == StatePaused, "market funding closed")
406 require(time.Now().Unix() < r.market.TradingEndsAt, "market funding closed")
407 amount := singleSend()
408 nextTotal := checkedAdd(totalHeldUgnot, amount)
409 assertBankCoverage(0, cur, nextTotal)
410 r.market.EscrowUgnot = checkedAdd(r.market.EscrowUgnot, amount)
411 r.market.ReserveUgnot = checkedAdd(r.market.ReserveUgnot, amount)
412 totalHeldUgnot = nextTotal
413 chain.Emit("Funded", "market", id, "amountUgnot", decimal(amount))
414}
415
416// Buy creates a position after exact payment of cost plus fee, within the
417// limits, and only while the market's escrow covers its larger side.
418// expectedPricePpm is the price of the chosen outcome that the buyer reviewed.
419// A buyer may sign with a session key: the position is always the account's.
420func Buy(cur realm, id, outcome string, microShares, expectedPricePpm int64) {
421 runtime.AssertOriginCall()
422 assertUser(0, cur)
423 r := mustMarket(id)
424 m := r.market
425 now := time.Now().Unix()
426 require(!realmPaused && m.State != StatePaused, "trading paused")
427 require(m.State == StateOpen && now < m.TradingEndsAt, "trading closed")
428 require(now-m.QuotedAt <= quoteTTL, "the price expired")
429 price := outcomePrice(m, outcome)
430 require(expectedPricePpm == price, "price does not match review")
431 require(microShares > 0 && microShares <= m.MaxTradeMicroShares, "above the trade limit")
432 cost := quoteCost(microShares, price)
433 fee := Fee(microShares, price, m.FeePpm)
434 require(singleSend() == checkedAdd(cost, fee), "send the exact cost plus fee")
435
436 owner := caller(0, cur)
437 p, held := r.positions[owner]
438 nextYes, nextNo := m.YesMicroShares, m.NoMicroShares
439 if outcome == "Yes" {
440 nextYes = checkedAdd(nextYes, microShares)
441 p.YesMicroShares = checkedAdd(p.YesMicroShares, microShares)
442 } else {
443 nextNo = checkedAdd(nextNo, microShares)
444 p.NoMicroShares = checkedAdd(p.NoMicroShares, microShares)
445 }
446 require(checkedAdd(p.YesMicroShares, p.NoMicroShares) <= m.MaxHolderMicroShares, "above the holder limit")
447 require(maximum(nextYes, nextNo) <= m.MaxSideMicroShares, "above the market limit")
448 nextEscrow := checkedAdd(m.EscrowUgnot, cost)
449 require(maximum(nextYes, nextNo) <= nextEscrow, "insufficient market collateral")
450 nextExposure := checkedAdd(openExposure-maximum(m.YesMicroShares, m.NoMicroShares), maximum(nextYes, nextNo))
451 require(nextExposure <= maxExposure, "above the realm exposure limit")
452 nextTotal := checkedAdd(totalHeldUgnot, checkedAdd(cost, fee))
453 assertBankCoverage(0, cur, nextTotal)
454
455 p.SpentUgnot = checkedAdd(p.SpentUgnot, cost)
456 p.FeeUgnot = checkedAdd(p.FeeUgnot, fee)
457 r.positions[owner] = p
458 if !held {
459 r.market.Holders++
460 }
461 r.market.YesMicroShares, r.market.NoMicroShares = nextYes, nextNo
462 r.market.EscrowUgnot = nextEscrow
463 r.market.FeesUgnot = checkedAdd(m.FeesUgnot, fee)
464 r.market.CostUgnot = checkedAdd(m.CostUgnot, cost)
465 r.market.FeesCollectedUgnot = checkedAdd(m.FeesCollectedUgnot, fee)
466 r.market.VolumeUgnot = checkedAdd(m.VolumeUgnot, cost)
467 r.market.Trades = checkedAdd(m.Trades, 1)
468 openExposure = nextExposure
469 totalHeldUgnot = nextTotal
470 chain.Emit("Bought", "market", id, "buyer", string(owner), "outcome", outcome, "microShares", decimal(microShares),
471 "pricePpm", decimal(price), "costUgnot", decimal(cost), "feeUgnot", decimal(fee), "version", decimal(m.QuoteVersion))
472}
473
474// ----- Resolution -----
475
476// ProposeOutcome records the oracle's Yes, No or Void with the market's entire
477// source binding and an evidence hash. Trading stops at once. After the
478// dispute window anyone can make it final; within it the guardian can cancel.
479// A Polymarket 50-50 result is proposed as Void.
480func ProposeOutcome(cur realm, id, conditionID, rulesHash, yesTokenID, noTokenID, outcome, evidenceHash string) {
481 assertOwnKey(0, cur)
482 r := mustMarket(id)
483 m := r.market
484 require(caller(0, cur) == m.Oracle, "oracle only")
485 require(m.State == StateOpen || m.State == StatePaused, "an outcome is already proposed or final")
486 require(conditionID == m.ConditionID && rulesHash == m.RulesHash && yesTokenID == m.YesTokenID && noTokenID == m.NoTokenID, "source binding mismatch")
487 require(outcome == "Yes" || outcome == "No" || outcome == "Void", "invalid outcome")
488 require(isHash(evidenceHash), "invalid evidence hash")
489 r.market.State = StateProposed
490 r.market.Outcome = outcome
491 r.market.EvidenceHash = evidenceHash
492 r.market.ProposedAt = time.Now().Unix()
493 chain.Emit("OutcomeProposed", "market", id, "outcome", outcome, "evidenceHash", evidenceHash, "finalFrom", decimal(r.market.ProposedAt+DisputeWindow))
494}
495
496// CancelProposal lets the guardian reject a proposed outcome within the
497// dispute window. The market returns to paused; the oracle can propose again.
498func CancelProposal(cur realm, id string) {
499 assertOwnKey(0, cur)
500 r := mustMarket(id)
501 m := r.market
502 require(caller(0, cur) == guardian, "guardian only")
503 require(m.State == StateProposed, "no proposed outcome")
504 require(time.Now().Unix() < m.ProposedAt+DisputeWindow, "the dispute window has passed")
505 r.market.State = StatePaused
506 r.market.Outcome, r.market.EvidenceHash, r.market.ProposedAt = "", "", 0
507 chain.Emit("ProposalCancelled", "market", id, "outcome", m.Outcome)
508}
509
510// Finalize makes a proposed outcome final once the dispute window has passed.
511// Anyone can call it.
512func Finalize(cur realm, id string) {
513 assertUser(0, cur)
514 assertNoSend()
515 r := mustMarket(id)
516 require(r.market.State == StateProposed, "no proposed outcome")
517 require(time.Now().Unix() >= r.market.ProposedAt+DisputeWindow, "the dispute window is still open")
518 settle(r, r.market.Outcome)
519 assertBankCoverage(0, cur, totalHeldUgnot)
520}
521
522// VoidAbandoned voids a market that is still unsettled AbandonAfter trading
523// ended. Anyone can call it; every holder then takes back what they paid.
524func VoidAbandoned(cur realm, id string) {
525 assertUser(0, cur)
526 assertNoSend()
527 r := mustMarket(id)
528 require(r.market.State == StateOpen || r.market.State == StatePaused, "an outcome is already proposed or final")
529 require(time.Now().Unix() >= r.market.AbandonAt, "not abandoned yet")
530 r.market.Outcome, r.market.EvidenceHash = "Void", ""
531 settle(r, "Void")
532 assertBankCoverage(0, cur, totalHeldUgnot)
533}
534
535func settle(r *marketRecord, outcome string) {
536 m := r.market
537 require(m.EscrowUgnot >= owedCollateral(m, outcome), "insufficient market collateral")
538 r.market.State = StateFinal
539 r.market.Outcome = outcome
540 r.market.FinalizedAt = time.Now().Unix()
541 openExposure -= maximum(m.YesMicroShares, m.NoMicroShares)
542 chain.Emit("Finalized", "market", m.ID, "outcome", outcome, "evidenceHash", r.market.EvidenceHash)
543}
544
545// ----- Money out -----
546
547// Claim settles the caller's position in a final market and deletes it. On Yes
548// or No each winning micro-share pays one ugnot; on Void the holder gets back
549// the cost and the fee they paid. A losing position closes with zero.
550func Claim(cur realm, id string) int64 {
551 assertUser(0, cur)
552 assertNoSend()
553 r := mustMarket(id)
554 m := r.market
555 require(m.State == StateFinal, "outcome is not final")
556 owner := caller(0, cur)
557 p, held := r.positions[owner]
558 require(held, "no position")
559 var fromEscrow, fromFees int64
560 switch m.Outcome {
561 case "Yes":
562 fromEscrow = p.YesMicroShares
563 case "No":
564 fromEscrow = p.NoMicroShares
565 default:
566 fromEscrow, fromFees = p.SpentUgnot, p.FeeUgnot
567 }
568 require(fromEscrow <= m.EscrowUgnot && fromFees <= m.FeesUgnot, "insufficient market collateral")
569 payout := fromEscrow + fromFees
570 assertBankCoverage(0, cur, totalHeldUgnot)
571 delete(r.positions, owner)
572 r.market.Holders--
573 r.market.EscrowUgnot -= fromEscrow
574 r.market.FeesUgnot -= fromFees
575 if m.Outcome == "Void" {
576 r.market.RefundedCostUgnot = checkedAdd(m.RefundedCostUgnot, fromEscrow)
577 r.market.RefundedFeesUgnot = checkedAdd(m.RefundedFeesUgnot, fromFees)
578 } else {
579 r.market.PaidUgnot = checkedAdd(m.PaidUgnot, fromEscrow)
580 }
581 totalHeldUgnot -= payout
582 if payout > 0 {
583 banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(cur.Address(), owner, chain.Coins{{Denom: "ugnot", Amount: payout}})
584 }
585 chain.Emit("Claimed", "market", id, "owner", string(owner), "outcome", m.Outcome, "payoutUgnot", decimal(payout),
586 "yesMicroShares", decimal(p.YesMicroShares), "noMicroShares", decimal(p.NoMicroShares), "spentUgnot", decimal(p.SpentUgnot), "feeUgnot", decimal(p.FeeUgnot))
587 return payout
588}
589
590// WithdrawSurplus sends what a final market no longer owes anyone to its fixed
591// treasury: the escrow above unclaimed payouts or refunds, and on Yes or No the
592// fees. Anyone can call it; the destination was fixed at listing.
593func WithdrawSurplus(cur realm, id string) int64 {
594 assertUser(0, cur)
595 assertNoSend()
596 r := mustMarket(id)
597 m := r.market
598 require(m.State == StateFinal, "outcome is not final")
599 collateral, fees := Surplus(id)
600 require(collateral >= 0 && fees >= 0, "insufficient market collateral")
601 amount := checkedAdd(collateral, fees)
602 require(amount > 0, "no surplus")
603 assertBankCoverage(0, cur, totalHeldUgnot)
604 r.market.EscrowUgnot -= collateral
605 r.market.FeesUgnot -= fees
606 r.market.WithdrawnUgnot = checkedAdd(m.WithdrawnUgnot, amount)
607 totalHeldUgnot -= amount
608 banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(cur.Address(), m.Treasury, chain.Coins{{Denom: "ugnot", Amount: amount}})
609 chain.Emit("SurplusWithdrawn", "market", id, "treasury", string(m.Treasury), "collateralUgnot", decimal(collateral), "feesUgnot", decimal(fees))
610 return amount
611}
612
613// Surplus returns what WithdrawSurplus would send now: collateral and fees.
614// It is zero for a market that is not final.
615func Surplus(id string) (collateralUgnot, feesUgnot int64) {
616 m := mustMarket(id).market
617 if m.State != StateFinal {
618 return 0, 0
619 }
620 return m.EscrowUgnot - owedCollateral(m, m.Outcome), m.FeesUgnot - owedFees(m, m.Outcome)
621}
622
623// owedCollateral is what the escrow must still pay holders for an outcome.
624func owedCollateral(m Market, outcome string) int64 {
625 switch outcome {
626 case "Yes":
627 return m.YesMicroShares - m.PaidUgnot
628 case "No":
629 return m.NoMicroShares - m.PaidUgnot
630 }
631 return m.CostUgnot - m.RefundedCostUgnot
632}
633
634// owedFees is what the fees must still return to holders: all of them on Void.
635func owedFees(m Market, outcome string) int64 {
636 if outcome == "Void" {
637 return m.FeesCollectedUgnot - m.RefundedFeesUgnot
638 }
639 return 0
640}
641
642// ----- Reads -----
643
644func GetMarket(id string) Market { return mustMarket(id).market }
645func GetPosition(id string, owner address) Position { return mustMarket(id).positions[owner] }
646func IsPaused() bool { return realmPaused }
647func QuoteTTL() int64 { return quoteTTL }
648
649// Quote returns the exact cost and fee for buying microShares of an outcome at
650// the current price. A buyer sends cost + fee.
651func Quote(id, outcome string, microShares int64) (costUgnot, feeUgnot int64) {
652 m := mustMarket(id).market
653 price := outcomePrice(m, outcome)
654 return quoteCost(microShares, price), Fee(microShares, price, m.FeePpm)
655}
656
657// Fee is the service fee in ugnot for microShares at pricePpm and rate feePpm:
658// θ × shares × price × (1 − price) GNOT, rounded up to the ugnot. It is computed
659// exactly as ceil(microShares × feePpm × price × (10^6 − price) / 10^18).
660func Fee(microShares, pricePpm, feePpm int64) int64 {
661 require(microShares > 0 && pricePpm > 0 && pricePpm < scale, "invalid quote amount")
662 require(feePpm >= 0 && feePpm <= MaxFeePpm, "fee rate above the cap")
663 if feePpm == 0 {
664 return 0
665 }
666 // pricePpm × (10^6 − pricePpm) ≤ 2.5 × 10^11; times feePpm ≤ 1.75 × 10^16.
667 rate := uint64(pricePpm) * uint64(scale-pricePpm) * uint64(feePpm)
668 hi, lo := bits.Mul64(uint64(microShares), rate)
669 const denominator uint64 = 1000000000000000000
670 quotient, remainder := bits.Div64(hi, lo, denominator) // hi < denominator for any int64 microShares
671 if remainder != 0 {
672 quotient++
673 }
674 require(quotient <= uint64(maxInt64), "fee overflow")
675 return int64(quotient)
676}
677
678func ListMarkets() []Market {
679 result := make([]Market, len(marketIDs))
680 for i, id := range marketIDs {
681 result[i] = records[id].market
682 }
683 return result
684}
685
686// QuoteHistory returns a detached copy of the published quotes.
687func QuoteHistory(id string) []QuotePoint {
688 points := mustMarket(id).history
689 result := make([]QuotePoint, len(points))
690 copy(result, points)
691 return result
692}
693
694// Hash UTF-8 length-framed text, shared exactly with the backend source binding.
695func BindingHash(question, rules string) string {
696 framed := strconv.FormatInt(int64(len(question)), 10) + ":" + question + strconv.FormatInt(int64(len(rules)), 10) + ":" + rules
697 sum := sha256.Sum256([]byte(framed))
698 return hex.EncodeToString(sum[:])
699}
700
701// ----- Checks -----
702
703func caller(_ int, context realm) address { return context.Previous().Address() }
704
705func assertUser(_ int, context realm) {
706 require(context.IsCurrent(), "invalid current realm")
707 require(context.Previous().IsUserCall(), "direct user call required")
708}
709
710// assertOwnKey also refuses a session key: listing, settings, roles, resuming
711// and resolution need the account's own signature. Session keys may only
712// quote, pause, buy and claim.
713func assertOwnKey(_ int, context realm) {
714 assertUser(0, context)
715 assertNoSend()
716 _, _, _, isSession := runtime.GetSessionInfo()
717 require(!isSession, "this action needs the account's own key, not a session key")
718}
719
720func assertNoSend() { require(len(unsafe.OriginSend()) == 0, "this action does not accept coins") }
721func singleSend() int64 {
722 sent := unsafe.OriginSend()
723 require(len(sent) == 1 && sent[0].Denom == "ugnot" && sent[0].Amount > 0, "send positive ugnot only")
724 return sent[0].Amount
725}
726func assertBankCoverage(_ int, context realm, required int64) {
727 require(banker.NewReadonlyBanker().GetCoin(context.Address(), "ugnot") >= required, "payment not credited to realm")
728}
729func mustMarket(id string) *marketRecord {
730 r := records[id]
731 require(r != nil, "unknown market")
732 return r
733}
734
735// checkQuote keeps each side inside the band and the pair at or above one
736// GNOT plus the minimum spread, so buying both sides can never be a sure gain.
737func checkQuote(yesPricePpm, noPricePpm int64) {
738 require(yesPricePpm >= MinPricePpm && yesPricePpm <= MaxPricePpm && noPricePpm >= MinPricePpm && noPricePpm <= MaxPricePpm, "price outside 0.03 to 0.97")
739 require(yesPricePpm+noPricePpm >= scale+minSpreadPpm, "Yes and No must add up to at least 1")
740}
741func outcomePrice(m Market, outcome string) int64 {
742 require(outcome == "Yes" || outcome == "No", "invalid outcome")
743 if outcome == "Yes" {
744 return m.YesPricePpm
745 }
746 return m.NoPricePpm
747}
748func quoteCost(microShares, price int64) int64 {
749 require(microShares > 0 && price > 0 && price < scale, "invalid quote amount")
750 require(microShares <= maxInt64/price, "quote overflow")
751 product := microShares * price
752 cost := product / scale
753 if product%scale != 0 {
754 cost = checkedAdd(cost, 1)
755 }
756 return cost
757}
758func checkedAdd(a, b int64) int64 {
759 require(a >= 0 && b >= 0 && a <= maxInt64-b, "amount overflow")
760 return a + b
761}
762func maximum(a, b int64) int64 {
763 if a > b {
764 return a
765 }
766 return b
767}
768func require(ok bool, message string) {
769 if !ok {
770 panic(message)
771 }
772}
773func isHash(s string) bool {
774 if len(s) != 64 {
775 return false
776 }
777 for _, c := range s {
778 if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
779 return false
780 }
781 }
782 return true
783}
784
785// isSlug keeps market IDs safe in paths and links: lowercase letters, digits
786// and single hyphens.
787func isSlug(s string) bool {
788 for i, c := range s {
789 ok := c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' && i > 0 && i < len(s)-1 && s[i-1] != '-'
790 if !ok {
791 return false
792 }
793 }
794 return true
795}
796
797// isTopic allows an empty topic or up to 24 letters and spaces, such as "Politics".
798func isTopic(s string) bool {
799 if len(s) > 24 {
800 return false
801 }
802 for _, c := range s {
803 if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == ' ') {
804 return false
805 }
806 }
807 return true
808}
809
810// isLabel allows an outcome name of 1 to 48 bytes of printable UTF-8 without
811// surrounding spaces or markup characters, such as "Yes" or "Los Angeles Dodgers".
812func isLabel(s string) bool {
813 if s == "" || len(s) > 48 || !utf8.ValidString(s) || strings.TrimSpace(s) != s {
814 return false
815 }
816 for _, c := range s {
817 if c < 0x20 || c == 0x7f || c >= 0x80 && c < 0xa0 || strings.ContainsRune("<>[]*_`|\\#", c) {
818 return false
819 }
820 }
821 return true
822}
823func isTokenID(s string) bool {
824 if len(s) == 0 || len(s) > 78 || len(s) > 1 && s[0] == '0' {
825 return false
826 }
827 for _, c := range s {
828 if c < '0' || c > '9' {
829 return false
830 }
831 }
832 return true
833}
834func decimal(value int64) string { return strconv.FormatInt(value, 10) }