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}