Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}