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

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}