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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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) }