// Package otc is an over-the-counter desk for GRC20 tokens against GNOT. // // A maker escrows one side of a trade in this realm; a taker fills it with // the other side, and both halves move in the same transaction or not at all. // Every offer has exactly one GRC20 side and one GNOT side (R1), is filled in // full or not at all (R8), and can be cancelled by its maker at any moment, // paused or not (R10). // // WHAT THE ADMIN CANNOT DO (A3). admin.gno sets fees within a constant cap, // manages the token whitelist, pauses new offers and fills, and hands the // role on. No function anywhere moves, freezes or cancels an escrow except // Fill (to the taker) and Cancel (to the maker, called by the maker). // // UPGRADES. A realm is immutable. If a bug is found the plan is: pause, deploy // a new path (.../otc/v2), and keep this one readable so makers can still // cancel here — which pause never blocks. package otc import ( "chain" "chain/banker" "chain/runtime/unsafe" "strconv" "strings" "time" "gno.land/p/nt/avl/v0" "gno.land/r/nt/grc20reg/v0" token "gno.land/r/nym-thegnomic001/gnomic" ) const ugnot = "ugnot" // Fixed limits. They are not configuration: config.gno is regenerated by // scripts/render.sh at deploy, and a security cap must be the one the tests // ran against. const ( // feeCapBps is the ceiling on any fee, forever (F5): 100 = 1%. feeCapBps = int64(100) // defaultPageSize and maxPageSize bound every list (S9). defaultPageSize = 20 maxPageSize = 50 // maxTokens bounds the whitelist, so GetConfig can list it in one read. maxTokens = 50 ) // Events. Every one carries the offer id and the addresses involved. const ( EventOfferCreated = "OfferCreated" EventOfferFilled = "OfferFilled" EventOfferCancelled = "OfferCancelled" EventAdminChanged = "AdminChanged" EventFeeChanged = "FeeChanged" EventFeeRecipientChanged = "FeeRecipientChanged" EventTokenChanged = "TokenChanged" EventPaused = "Paused" ) var ( admin address feeRecipient address realmAddr address webPath string // "/r//otc", for gnoweb links feeBps = defaultFeeBps gnomicKey string gnomicFeeOn = gnomicFeeEnabled gnomicFee = gnomicFeeBps paused bool // tokens: registry key -> enabled (W1, W2). tokens avl.Tree // offers: padded id -> *Offer, every offer ever made (R13). offers avl.Tree // openPublic: padded id -> nil, only offers that are open AND public (R4). openPublic avl.Tree // byMaker: maker -> *avl.Tree of padded id -> nil, every offer of that // maker in any state, private ones included. byMaker avl.Tree lastID uint64 totalFees int64 // ugnot sent to the fee recipient (F7) totalVolume int64 // sum of GnotAmt over filled offers (F7) filledCount int64 ) func init(cur realm) { realmAddr = cur.Address() webPath = strings.TrimPrefix(cur.PkgPath(), "gno.land") // Only an EMPTY address falls back. A configured one that does not parse // stops the deploy: a typo must not quietly hand the admin role, or the // fees, to whoever signed the deploy. if adminAddress == "" { admin = cur.Previous().Address() if !admin.IsValid() { admin = unsafe.OriginCaller() } } else { admin = address(adminAddress) } if !admin.IsValid() || admin == realmAddr { panic("otc: adminAddress is not a valid address: " + adminAddress) } feeRecipient = admin if feeRecipientAddress != "" { feeRecipient = address(feeRecipientAddress) if !feeRecipient.IsValid() { panic("otc: feeRecipientAddress is not a valid address: " + feeRecipientAddress) } } if feeRecipient == realmAddr { panic(ErrFeeToRealm) } if defaultFeeBps < 0 || defaultFeeBps > feeCapBps || gnomicFeeBps < 0 || gnomicFeeBps > feeCapBps { panic(ErrFeeAboveCap) } // The key comes from the imported realm; the constant only exists so a // wrong key in config.env dies here, at deploy, and not at the first offer. gnomicKey = token.TokenKey() if gnomicKey != gnomicTokenKey { panic("otc: config.gno names " + gnomicTokenKey + " but the imported token realm is " + gnomicKey) } tokens.Set(gnomicKey, true) } // --------------------------------------------------------------------------- // Making an offer // --------------------------------------------------------------------------- // CreateSellToken escrows tokenAmt of the token and asks gnotAmt ugnot for it. // The maker must first Approve this realm's address for exactly tokenAmt in // the token's realm; only that amount is pulled (S4). Returns the offer id. func CreateSellToken(cur realm, tokenKey string, tokenAmt, gnotAmt int64, taker string, expiresAt int64) uint64 { requireNoCoins() // S2 maker := cur.Previous().Address() o := newOffer(maker, SellToken, tokenKey, tokenAmt, gnotAmt, taker, expiresAt) index(o) // S3: state before transfers // Moves exactly tokenAmt or panics: grc20reg only holds p/nt/grc20 tokens, // and it turns their error into a panic that reverts the offer above (S4). grc20reg.TransferFrom(0, cur, tokenKey, maker, realmAddr, tokenAmt) emitCreated(o) return o.ID } // CreateSellGnot escrows the ugnot sent with the call and asks tokenAmt of // the token for it. Only a direct user call may attach coins (S1). Returns // the offer id. func CreateSellGnot(cur realm, tokenKey string, tokenAmt int64, taker string, expiresAt int64) uint64 { requireUser(cur) // S1 gnotAmt := sentUgnot() maker := cur.Previous().Address() o := newOffer(maker, SellGnot, tokenKey, tokenAmt, gnotAmt, taker, expiresAt) index(o) emitCreated(o) return o.ID } func newOffer(maker address, side Side, tokenKey string, tokenAmt, gnotAmt int64, taker string, expiresAt int64) *Offer { if paused { panic(ErrPaused) // A2 } if tokenAmt <= 0 || gnotAmt <= 0 { panic(ErrBadAmount) // R2, S5 } requireTokenEnabled(tokenKey) // R3 now := time.Now().Unix() // S8 if expiresAt < 0 || (expiresAt != 0 && expiresAt <= now) { panic(ErrBadExpiry) // R9 } t := address("") if taker != "" { t = address(taker) if !t.IsValid() || t == realmAddr { panic(ErrBadAddress) // S6 } if t == maker { panic(ErrTakerIsMaker) // R6 } } fee := feeFor(tokenKey) if fee < 0 || fee > feeCapBps { panic(ErrFeeAboveCap) // F5 } lastID++ return &Offer{ ID: lastID, Maker: maker, Taker: t, Side: side, TokenKey: tokenKey, TokenAmt: tokenAmt, GnotAmt: gnotAmt, FeeBps: fee, ExpiresAt: expiresAt, CreatedAt: now, Status: Open, } } func index(o *Offer) { id := padID(o.ID) offers.Set(id, o) if o.Taker == "" { openPublic.Set(id, nil) } var mine *avl.Tree if v := byMaker.Get(o.Maker.String()); v != nil { mine = v.(*avl.Tree) } else { mine = avl.NewTree() byMaker.Set(o.Maker.String(), mine) } mine.Set(id, nil) } // --------------------------------------------------------------------------- // Taking an offer // --------------------------------------------------------------------------- // Fill takes an open offer in full (R8). // // - SellToken: attach exactly GnotAmt ugnot; direct user call only (S1). // - SellGnot: attach nothing (S2) and first Approve this realm for exactly // TokenAmt; the tokens go straight from the taker to the maker. // // The fee comes off the GNOT side and goes to the fee recipient in the same // call (F2, F3). func Fill(cur realm, id uint64) { if paused { panic(ErrPaused) // A2 } o := mustOffer(id) if o.Status != Open { panic(ErrNotOpen) // R12 } if o.Side == SellToken { requireUser(cur) if sentUgnot() != o.GnotAmt { panic(ErrBadPayment) // R8 } } else { requireNoCoins() } taker := cur.Previous().Address() if taker == o.Maker { panic(ErrOwnOffer) // R7 } if o.Taker != "" && o.Taker != taker { panic(ErrNotTaker) // R5 } if expired(o) { panic(ErrExpired) // R9 } fee := feeOf(o.GnotAmt, o.FeeBps) net := o.GnotAmt - fee // S3: close the offer and count it before anything moves. closeOffer(o, Filled) totalFees = addSat(totalFees, fee) totalVolume = addSat(totalVolume, o.GnotAmt) filledCount = addSat(filledCount, 1) if o.Side == SellToken { grc20reg.Transfer(0, cur, o.TokenKey, taker, o.TokenAmt) pay(cur, o.Maker, net) } else { grc20reg.TransferFrom(0, cur, o.TokenKey, taker, o.Maker, o.TokenAmt) pay(cur, taker, net) } pay(cur, feeRecipient, fee) // F7: skipped when 0 chain.Emit(EventOfferFilled, "id", strconv.FormatUint(o.ID, 10), "maker", o.Maker.String(), "taker", taker.String(), "side", o.Side.String(), "token", o.TokenKey, "token_amt", strconv.FormatInt(o.TokenAmt, 10), "gnot_amt", strconv.FormatInt(o.GnotAmt, 10), "fee", strconv.FormatInt(fee, 10)) } // --------------------------------------------------------------------------- // Withdrawing an offer // --------------------------------------------------------------------------- // Cancel returns the escrow to the maker. Only the maker can call it (R11), // at any time, including after expiry and while paused (R10). func Cancel(cur realm, id uint64) { requireNoCoins() // S2 o := mustOffer(id) if o.Maker != cur.Previous().Address() { panic(ErrNotMaker) // S7 } if o.Status != Open { panic(ErrNotOpen) // R12 } closeOffer(o, Cancelled) // S3 if o.Side == SellToken { grc20reg.Transfer(0, cur, o.TokenKey, o.Maker, o.TokenAmt) } else { pay(cur, o.Maker, o.GnotAmt) } chain.Emit(EventOfferCancelled, "id", strconv.FormatUint(o.ID, 10), "maker", o.Maker.String()) } // --------------------------------------------------------------------------- // The small machinery // --------------------------------------------------------------------------- func closeOffer(o *Offer, s Status) { o.Status = s openPublic.Remove(padID(o.ID)) } // expired is true once block time reaches ExpiresAt (R9). An expired offer // stays Open until its maker cancels it (R10, R11). func expired(o *Offer) bool { return o.ExpiresAt != 0 && time.Now().Unix() >= o.ExpiresAt } func mustOffer(id uint64) *Offer { v := offers.Get(padID(id)) if v == nil { panic(ErrNoOffer) } return v.(*Offer) } func requireTokenEnabled(key string) { v := tokens.Get(key) if v == nil { panic(ErrTokenNotListed) } if !v.(bool) { panic(ErrTokenDisabled) } } // requireUser insists on a direct user call (S1). The coin envelope belongs // to the whole message, so a realm in the middle could spend it elsewhere // and leave OriginSend describing a payment this realm never received — the // "phantom payment" of atomicswap.NewCustomCoinSwap. `maketx run` is refused // for the same reason. func requireUser(cur realm) { if !cur.Previous().IsUserCall() { panic(ErrNotUser) } } // sentUgnot returns the ugnot attached to the call, and refuses any envelope // that is not exactly one positive ugnot coin. func sentUgnot() int64 { sent := unsafe.OriginSend() if len(sent) != 1 || sent[0].Denom != ugnot || sent[0].Amount <= 0 { panic(ErrBadPayment) } return sent[0].Amount } // requireNoCoins refuses any coin on a call that is not meant to receive // one (S2): this realm has no way to send stray coins back. func requireNoCoins() { for _, c := range unsafe.OriginSend() { if c.Amount != 0 { panic(ErrNoCoins) } } } func pay(cur realm, to address, amount int64) { if amount <= 0 { return } banker.NewBanker(banker.BankerTypeRealmSend, cur). SendCoins(realmAddr, to, chain.NewCoins(chain.NewCoin(ugnot, amount))) } // padID makes ids sort as numbers in the trees. func padID(id uint64) string { s := strconv.FormatUint(id, 10) return strings.Repeat("0", 20-len(s)) + s } func emitCreated(o *Offer) { chain.Emit(EventOfferCreated, "id", strconv.FormatUint(o.ID, 10), "maker", o.Maker.String(), "taker", o.Taker.String(), "side", o.Side.String(), "token", o.TokenKey, "token_amt", strconv.FormatInt(o.TokenAmt, 10), "gnot_amt", strconv.FormatInt(o.GnotAmt, 10), "fee_bps", strconv.FormatInt(o.FeeBps, 10)) }