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.

otc.gno

11.66 Kb · 402 lines
  1// Package otc is an over-the-counter desk for GRC20 tokens against GNOT.
  2//
  3// A maker escrows one side of a trade in this realm; a taker fills it with
  4// the other side, and both halves move in the same transaction or not at all.
  5// Every offer has exactly one GRC20 side and one GNOT side (R1), is filled in
  6// full or not at all (R8), and can be cancelled by its maker at any moment,
  7// paused or not (R10).
  8//
  9// WHAT THE ADMIN CANNOT DO (A3). admin.gno sets fees within a constant cap,
 10// manages the token whitelist, pauses new offers and fills, and hands the
 11// role on. No function anywhere moves, freezes or cancels an escrow except
 12// Fill (to the taker) and Cancel (to the maker, called by the maker).
 13//
 14// UPGRADES. A realm is immutable. If a bug is found the plan is: pause, deploy
 15// a new path (.../otc/v2), and keep this one readable so makers can still
 16// cancel here — which pause never blocks.
 17package otc
 18
 19import (
 20	"chain"
 21	"chain/banker"
 22	"chain/runtime/unsafe"
 23	"strconv"
 24	"strings"
 25	"time"
 26
 27	"gno.land/p/nt/avl/v0"
 28	"gno.land/r/nt/grc20reg/v0"
 29
 30	token "gno.land/r/nym-thegnomic001/gnomic"
 31)
 32
 33const ugnot = "ugnot"
 34
 35// Fixed limits. They are not configuration: config.gno is regenerated by
 36// scripts/render.sh at deploy, and a security cap must be the one the tests
 37// ran against.
 38const (
 39	// feeCapBps is the ceiling on any fee, forever (F5): 100 = 1%.
 40	feeCapBps = int64(100)
 41
 42	// defaultPageSize and maxPageSize bound every list (S9).
 43	defaultPageSize = 20
 44	maxPageSize     = 50
 45
 46	// maxTokens bounds the whitelist, so GetConfig can list it in one read.
 47	maxTokens = 50
 48)
 49
 50// Events. Every one carries the offer id and the addresses involved.
 51const (
 52	EventOfferCreated        = "OfferCreated"
 53	EventOfferFilled         = "OfferFilled"
 54	EventOfferCancelled      = "OfferCancelled"
 55	EventAdminChanged        = "AdminChanged"
 56	EventFeeChanged          = "FeeChanged"
 57	EventFeeRecipientChanged = "FeeRecipientChanged"
 58	EventTokenChanged        = "TokenChanged"
 59	EventPaused              = "Paused"
 60)
 61
 62var (
 63	admin        address
 64	feeRecipient address
 65	realmAddr    address
 66	webPath      string // "/r/<namespace>/otc", for gnoweb links
 67
 68	feeBps      = defaultFeeBps
 69	gnomicKey   string
 70	gnomicFeeOn = gnomicFeeEnabled
 71	gnomicFee   = gnomicFeeBps
 72	paused      bool
 73
 74	// tokens: registry key -> enabled (W1, W2).
 75	tokens avl.Tree
 76
 77	// offers: padded id -> *Offer, every offer ever made (R13).
 78	offers avl.Tree
 79	// openPublic: padded id -> nil, only offers that are open AND public (R4).
 80	openPublic avl.Tree
 81	// byMaker: maker -> *avl.Tree of padded id -> nil, every offer of that
 82	// maker in any state, private ones included.
 83	byMaker avl.Tree
 84
 85	lastID uint64
 86
 87	totalFees   int64 // ugnot sent to the fee recipient (F7)
 88	totalVolume int64 // sum of GnotAmt over filled offers (F7)
 89	filledCount int64
 90)
 91
 92func init(cur realm) {
 93	realmAddr = cur.Address()
 94	webPath = strings.TrimPrefix(cur.PkgPath(), "gno.land")
 95
 96	// Only an EMPTY address falls back. A configured one that does not parse
 97	// stops the deploy: a typo must not quietly hand the admin role, or the
 98	// fees, to whoever signed the deploy.
 99	if adminAddress == "" {
100		admin = cur.Previous().Address()
101		if !admin.IsValid() {
102			admin = unsafe.OriginCaller()
103		}
104	} else {
105		admin = address(adminAddress)
106	}
107	if !admin.IsValid() || admin == realmAddr {
108		panic("otc: adminAddress is not a valid address: " + adminAddress)
109	}
110
111	feeRecipient = admin
112	if feeRecipientAddress != "" {
113		feeRecipient = address(feeRecipientAddress)
114		if !feeRecipient.IsValid() {
115			panic("otc: feeRecipientAddress is not a valid address: " + feeRecipientAddress)
116		}
117	}
118	if feeRecipient == realmAddr {
119		panic(ErrFeeToRealm)
120	}
121	if defaultFeeBps < 0 || defaultFeeBps > feeCapBps || gnomicFeeBps < 0 || gnomicFeeBps > feeCapBps {
122		panic(ErrFeeAboveCap)
123	}
124
125	// The key comes from the imported realm; the constant only exists so a
126	// wrong key in config.env dies here, at deploy, and not at the first offer.
127	gnomicKey = token.TokenKey()
128	if gnomicKey != gnomicTokenKey {
129		panic("otc: config.gno names " + gnomicTokenKey + " but the imported token realm is " + gnomicKey)
130	}
131	tokens.Set(gnomicKey, true)
132}
133
134// ---------------------------------------------------------------------------
135// Making an offer
136// ---------------------------------------------------------------------------
137
138// CreateSellToken escrows tokenAmt of the token and asks gnotAmt ugnot for it.
139// The maker must first Approve this realm's address for exactly tokenAmt in
140// the token's realm; only that amount is pulled (S4). Returns the offer id.
141func CreateSellToken(cur realm, tokenKey string, tokenAmt, gnotAmt int64, taker string, expiresAt int64) uint64 {
142	requireNoCoins() // S2
143	maker := cur.Previous().Address()
144	o := newOffer(maker, SellToken, tokenKey, tokenAmt, gnotAmt, taker, expiresAt)
145	index(o) // S3: state before transfers
146
147	// Moves exactly tokenAmt or panics: grc20reg only holds p/nt/grc20 tokens,
148	// and it turns their error into a panic that reverts the offer above (S4).
149	grc20reg.TransferFrom(0, cur, tokenKey, maker, realmAddr, tokenAmt)
150
151	emitCreated(o)
152	return o.ID
153}
154
155// CreateSellGnot escrows the ugnot sent with the call and asks tokenAmt of
156// the token for it. Only a direct user call may attach coins (S1). Returns
157// the offer id.
158func CreateSellGnot(cur realm, tokenKey string, tokenAmt int64, taker string, expiresAt int64) uint64 {
159	requireUser(cur) // S1
160	gnotAmt := sentUgnot()
161	maker := cur.Previous().Address()
162	o := newOffer(maker, SellGnot, tokenKey, tokenAmt, gnotAmt, taker, expiresAt)
163	index(o)
164	emitCreated(o)
165	return o.ID
166}
167
168func newOffer(maker address, side Side, tokenKey string, tokenAmt, gnotAmt int64, taker string, expiresAt int64) *Offer {
169	if paused {
170		panic(ErrPaused) // A2
171	}
172	if tokenAmt <= 0 || gnotAmt <= 0 {
173		panic(ErrBadAmount) // R2, S5
174	}
175	requireTokenEnabled(tokenKey) // R3
176	now := time.Now().Unix()      // S8
177	if expiresAt < 0 || (expiresAt != 0 && expiresAt <= now) {
178		panic(ErrBadExpiry) // R9
179	}
180	t := address("")
181	if taker != "" {
182		t = address(taker)
183		if !t.IsValid() || t == realmAddr {
184			panic(ErrBadAddress) // S6
185		}
186		if t == maker {
187			panic(ErrTakerIsMaker) // R6
188		}
189	}
190	fee := feeFor(tokenKey)
191	if fee < 0 || fee > feeCapBps {
192		panic(ErrFeeAboveCap) // F5
193	}
194	lastID++
195	return &Offer{
196		ID: lastID, Maker: maker, Taker: t, Side: side,
197		TokenKey: tokenKey, TokenAmt: tokenAmt, GnotAmt: gnotAmt,
198		FeeBps: fee, ExpiresAt: expiresAt, CreatedAt: now, Status: Open,
199	}
200}
201
202func index(o *Offer) {
203	id := padID(o.ID)
204	offers.Set(id, o)
205	if o.Taker == "" {
206		openPublic.Set(id, nil)
207	}
208	var mine *avl.Tree
209	if v := byMaker.Get(o.Maker.String()); v != nil {
210		mine = v.(*avl.Tree)
211	} else {
212		mine = avl.NewTree()
213		byMaker.Set(o.Maker.String(), mine)
214	}
215	mine.Set(id, nil)
216}
217
218// ---------------------------------------------------------------------------
219// Taking an offer
220// ---------------------------------------------------------------------------
221
222// Fill takes an open offer in full (R8).
223//
224//   - SellToken: attach exactly GnotAmt ugnot; direct user call only (S1).
225//   - SellGnot: attach nothing (S2) and first Approve this realm for exactly
226//     TokenAmt; the tokens go straight from the taker to the maker.
227//
228// The fee comes off the GNOT side and goes to the fee recipient in the same
229// call (F2, F3).
230func Fill(cur realm, id uint64) {
231	if paused {
232		panic(ErrPaused) // A2
233	}
234	o := mustOffer(id)
235	if o.Status != Open {
236		panic(ErrNotOpen) // R12
237	}
238	if o.Side == SellToken {
239		requireUser(cur)
240		if sentUgnot() != o.GnotAmt {
241			panic(ErrBadPayment) // R8
242		}
243	} else {
244		requireNoCoins()
245	}
246	taker := cur.Previous().Address()
247	if taker == o.Maker {
248		panic(ErrOwnOffer) // R7
249	}
250	if o.Taker != "" && o.Taker != taker {
251		panic(ErrNotTaker) // R5
252	}
253	if expired(o) {
254		panic(ErrExpired) // R9
255	}
256
257	fee := feeOf(o.GnotAmt, o.FeeBps)
258	net := o.GnotAmt - fee
259
260	// S3: close the offer and count it before anything moves.
261	closeOffer(o, Filled)
262	totalFees = addSat(totalFees, fee)
263	totalVolume = addSat(totalVolume, o.GnotAmt)
264	filledCount = addSat(filledCount, 1)
265
266	if o.Side == SellToken {
267		grc20reg.Transfer(0, cur, o.TokenKey, taker, o.TokenAmt)
268		pay(cur, o.Maker, net)
269	} else {
270		grc20reg.TransferFrom(0, cur, o.TokenKey, taker, o.Maker, o.TokenAmt)
271		pay(cur, taker, net)
272	}
273	pay(cur, feeRecipient, fee) // F7: skipped when 0
274
275	chain.Emit(EventOfferFilled,
276		"id", strconv.FormatUint(o.ID, 10),
277		"maker", o.Maker.String(),
278		"taker", taker.String(),
279		"side", o.Side.String(),
280		"token", o.TokenKey,
281		"token_amt", strconv.FormatInt(o.TokenAmt, 10),
282		"gnot_amt", strconv.FormatInt(o.GnotAmt, 10),
283		"fee", strconv.FormatInt(fee, 10))
284}
285
286// ---------------------------------------------------------------------------
287// Withdrawing an offer
288// ---------------------------------------------------------------------------
289
290// Cancel returns the escrow to the maker. Only the maker can call it (R11),
291// at any time, including after expiry and while paused (R10).
292func Cancel(cur realm, id uint64) {
293	requireNoCoins() // S2
294	o := mustOffer(id)
295	if o.Maker != cur.Previous().Address() {
296		panic(ErrNotMaker) // S7
297	}
298	if o.Status != Open {
299		panic(ErrNotOpen) // R12
300	}
301	closeOffer(o, Cancelled) // S3
302
303	if o.Side == SellToken {
304		grc20reg.Transfer(0, cur, o.TokenKey, o.Maker, o.TokenAmt)
305	} else {
306		pay(cur, o.Maker, o.GnotAmt)
307	}
308
309	chain.Emit(EventOfferCancelled,
310		"id", strconv.FormatUint(o.ID, 10),
311		"maker", o.Maker.String())
312}
313
314// ---------------------------------------------------------------------------
315// The small machinery
316// ---------------------------------------------------------------------------
317
318func closeOffer(o *Offer, s Status) {
319	o.Status = s
320	openPublic.Remove(padID(o.ID))
321}
322
323// expired is true once block time reaches ExpiresAt (R9). An expired offer
324// stays Open until its maker cancels it (R10, R11).
325func expired(o *Offer) bool {
326	return o.ExpiresAt != 0 && time.Now().Unix() >= o.ExpiresAt
327}
328
329func mustOffer(id uint64) *Offer {
330	v := offers.Get(padID(id))
331	if v == nil {
332		panic(ErrNoOffer)
333	}
334	return v.(*Offer)
335}
336
337func requireTokenEnabled(key string) {
338	v := tokens.Get(key)
339	if v == nil {
340		panic(ErrTokenNotListed)
341	}
342	if !v.(bool) {
343		panic(ErrTokenDisabled)
344	}
345}
346
347// requireUser insists on a direct user call (S1). The coin envelope belongs
348// to the whole message, so a realm in the middle could spend it elsewhere
349// and leave OriginSend describing a payment this realm never received — the
350// "phantom payment" of atomicswap.NewCustomCoinSwap. `maketx run` is refused
351// for the same reason.
352func requireUser(cur realm) {
353	if !cur.Previous().IsUserCall() {
354		panic(ErrNotUser)
355	}
356}
357
358// sentUgnot returns the ugnot attached to the call, and refuses any envelope
359// that is not exactly one positive ugnot coin.
360func sentUgnot() int64 {
361	sent := unsafe.OriginSend()
362	if len(sent) != 1 || sent[0].Denom != ugnot || sent[0].Amount <= 0 {
363		panic(ErrBadPayment)
364	}
365	return sent[0].Amount
366}
367
368// requireNoCoins refuses any coin on a call that is not meant to receive
369// one (S2): this realm has no way to send stray coins back.
370func requireNoCoins() {
371	for _, c := range unsafe.OriginSend() {
372		if c.Amount != 0 {
373			panic(ErrNoCoins)
374		}
375	}
376}
377
378func pay(cur realm, to address, amount int64) {
379	if amount <= 0 {
380		return
381	}
382	banker.NewBanker(banker.BankerTypeRealmSend, cur).
383		SendCoins(realmAddr, to, chain.NewCoins(chain.NewCoin(ugnot, amount)))
384}
385
386// padID makes ids sort as numbers in the trees.
387func padID(id uint64) string {
388	s := strconv.FormatUint(id, 10)
389	return strings.Repeat("0", 20-len(s)) + s
390}
391
392func emitCreated(o *Offer) {
393	chain.Emit(EventOfferCreated,
394		"id", strconv.FormatUint(o.ID, 10),
395		"maker", o.Maker.String(),
396		"taker", o.Taker.String(),
397		"side", o.Side.String(),
398		"token", o.TokenKey,
399		"token_amt", strconv.FormatInt(o.TokenAmt, 10),
400		"gnot_amt", strconv.FormatInt(o.GnotAmt, 10),
401		"fee_bps", strconv.FormatInt(o.FeeBps, 10))
402}