Transaction

B70D79BB03F252…EFB8D0704945

Block 380,568 · index 0 · indexed

Summary

Hash
B70D79BB03F25217A49F830144095AD6ED5B8AB7DBDF13A7CA90EFB8D0704945
Block
380,568
Size
28497 bytes
Gas used
41,882,390 / 150,000,000
Fee
250000ugnot
Status
success

Messages

#1AddPackagegno.land/r/nym-thegnomic001/otc17 arguments

Arguments · 17

  1. #1otc
  2. #2admin.gno
  3. #3package otc import ( "chain" "strconv" "gno.land/r/nt/grc20reg/v0" ) // The admin's whole reach (A1). None of these touches an offer or an escrow // (A3); every one refuses coins (S2) and emits an event. // AddToken whitelists a token by its grc20reg key, enabled (W2). func AddToken(cur realm, key string) { requireAdmin(cur) if grc20reg.Get(key) == nil { panic(ErrTokenUnknown) } if tokens.Has(key) { panic(ErrTokenExists) } if tokens.Size() >= maxTokens { panic(ErrTooManyTokens) } tokens.Set(key, true) chain.Emit(EventTokenChanged, "token", key, "enabled", "true") } // SetTokenEnabled switches new offers on a token on or off. Open offers on a // disabled token can still be filled and cancelled (W2). func SetTokenEnabled(cur realm, key string, enabled bool) { requireAdmin(cur) if !tokens.Has(key) { panic(ErrTokenNotListed) } tokens.Set(key, enabled) chain.Emit(EventTokenChanged, "token", key, "enabled", strconv.FormatBool(enabled)) } // SetFeeBps sets the default fee for NEW offers (F4, F5). func SetFeeBps(cur realm, bps int64) { requireAdmin(cur) requireFee(bps) feeBps = bps chain.Emit(EventFeeChanged, "fee_bps", strconv.FormatInt(bps, 10)) } // SetGnomicFee turns the reduced GNOMIC fee on or off and sets it (F6). func SetGnomicFee(cur realm, enabled bool, bps int64) { requireAdmin(cur) requireFee(bps) gnomicFeeOn = enabled gnomicFee = bps chain.Emit(EventFeeChanged, "gnomic_fee_enabled", strconv.FormatBool(enabled), "gnomic_fee_bps", strconv.FormatInt(bps, 10)) } // SetFeeRecipient changes where fees go. func SetFeeRecipient(cur realm, to address) { requireAdmin(cur) if !to.IsValid() { panic(ErrBadAddress) } if to == realmAddr { panic(ErrFeeToRealm) } old := feeRecipient feeRecipient = to chain.Emit(EventFeeRecipientChanged, "old", old.String(), "new", to.String()) } // SetPaused blocks or allows new offers and fills. It never blocks Cancel // (A2, R10). func SetPaused(cur realm, p bool) { requireAdmin(cur) paused = p chain.Emit(EventPaused, "paused", strconv.FormatBool(p)) } // TransferAdmin hands the admin role to another address. func TransferAdmin(cur realm, to address) { requireAdmin(cur) if !to.IsValid() || to == realmAddr { panic(ErrBadAddress) } old := admin admin = to chain.Emit(EventAdminChanged, "old", old.String(), "new", to.String()) } func requireAdmin(cur realm) { requireNoCoins() if cur.Previous().Address() != admin { panic(ErrNotAdmin) // S7 } } func requireFee(bps int64) { if bps < 0 || bps > feeCapBps { panic(ErrFeeAboveCap) } }
  4. #4config.gno
  5. #5package otc // GENERATED by scripts/render.sh from config.env. Do not edit by hand. const ( // adminAddress holds the knobs in admin.gno. It cannot move, freeze or // cancel anybody's escrow. Empty = the deployer; anything else must be a // valid address or init refuses to deploy. adminAddress = "g1z252jewcp2gaxs9h2mln2edqce49t7sntl4nes" // feeRecipientAddress receives every fee, in the fill that earns it. // Empty = the admin; anything else must be a valid address. feeRecipientAddress = "g1qlkugyn80ur88mqu9jdqaawl4j54xvfhdu6alk" // gnomicTokenKey is checked against the imported token realm in init(). gnomicTokenKey = "gno.land/r/nym-thegnomic001/gnomic.GNOMIC" // defaultFeeBps: 0.30%. gnomicFeeBps: 0.10% on GNOMIC offers. defaultFeeBps = int64(30) gnomicFeeEnabled = true gnomicFeeBps = int64(10) )
  6. #6fee.gno
  7. #7package otc const bpsDenominator = 10000 // feeOf is bps of amount, rounded down (F1). Split so that a large amount // cannot overflow on the multiplication: bps is at most feeCapBps, so both // terms stay far below int64 max. func feeOf(amount, bps int64) int64 { if amount <= 0 || bps <= 0 { return 0 } return amount/bpsDenominator*bps + amount%bpsDenominator*bps/bpsDenominator } // feeFor picks the fee a new offer on tokenKey is created with (F4, F6). func feeFor(tokenKey string) int64 { if gnomicFeeOn && tokenKey == gnomicKey { return gnomicFee } return feeBps } // addSat adds two non-negative totals without wrapping (S5). The totals are // statistics, not money: one that stops at the ceiling is better than one // that turns negative. func addSat(a, b int64) int64 { if b > 0 && a > maxInt64-b { return maxInt64 } return a + b } const maxInt64 = int64(9223372036854775807)
  8. #8gnomod.toml
  9. #9module = "gno.land/r/nym-thegnomic001/otc" gno = "0.9"
  10. #10otc.gno
  11. #11// 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/<namespace>/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)) }
  12. #12render.gno
  13. #13package otc import ( "strconv" "strings" "time" "gno.land/p/nt/avl/v0" "gno.land/r/nt/grc20reg/v0" ) // Reads do not refuse coins: the envelope belongs to the whole message, so a // guard here would break any realm that reads an offer inside a paid call of // its own. Only writes are guarded. // // Amounts in JSON are strings: int64 does not fit a JavaScript number. func RealmAddress() address { return realmAddr } // GetOffer returns one offer as JSON, public or private. func GetOffer(id uint64) string { return offerJSON(mustOffer(id)) } // ListOpen returns open public offers, newest first (S9). func ListOpen(offset, limit int) string { return listJSON(&openPublic, offset, limit) } // ListByMaker returns every offer of one maker, private and closed ones // included, newest first. func ListByMaker(addr string, offset, limit int) string { v := byMaker.Get(addr) if v == nil { return `{"total":0,"offers":[]}` } return listJSON(v.(*avl.Tree), offset, limit) } // GetConfig returns the parameters and the whitelist as JSON. func GetConfig() string { var b strings.Builder b.WriteString(`{"realm":` + jstr(realmAddr.String())) b.WriteString(`,"admin":` + jstr(admin.String())) b.WriteString(`,"fee_recipient":` + jstr(feeRecipient.String())) b.WriteString(`,"fee_bps":` + num(feeBps)) b.WriteString(`,"fee_cap_bps":` + num(feeCapBps)) b.WriteString(`,"gnomic_key":` + jstr(gnomicKey)) b.WriteString(`,"gnomic_fee_enabled":` + strconv.FormatBool(gnomicFeeOn)) b.WriteString(`,"gnomic_fee_bps":` + num(gnomicFee)) b.WriteString(`,"paused":` + strconv.FormatBool(paused)) b.WriteString(`,"max_page_size":` + strconv.Itoa(maxPageSize)) b.WriteString(`,"tokens":[`) i := 0 tokens.Iterate("", "", func(key string, v any) bool { // bounded by maxTokens if i > 0 { b.WriteString(",") } i++ sym, dec := tokenInfo(key) b.WriteString(`{"key":` + jstr(key) + `,"symbol":` + jstr(sym) + `,"decimals":` + strconv.Itoa(dec) + `,"enabled":` + strconv.FormatBool(v.(bool)) + `}`) return false }) b.WriteString("]}") return b.String() } // GetStats returns the running totals as JSON (F7). func GetStats() string { return `{"total_fees_ugnot":` + jstr(num(totalFees)) + `,"total_volume_ugnot":` + jstr(num(totalVolume)) + `,"filled":` + num(filledCount) + `,"offers":` + strconv.FormatUint(lastID, 10) + `,"open_public":` + strconv.Itoa(openPublic.Size()) + `}` } // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- // Render serves "" (stats and the first page), "page/N" and "offer/ID". func Render(path string) string { switch { case path == "": return renderHome(1) case strings.HasPrefix(path, "page/"): n, err := strconv.Atoi(path[len("page/"):]) if err != nil || n < 1 { return "# Not found\n" } return renderHome(n) case strings.HasPrefix(path, "offer/"): id, err := strconv.ParseUint(path[len("offer/"):], 10, 64) if err != nil || !offers.Has(padID(id)) { return "# Not found\n" } return renderOffer(mustOffer(id)) } return "# Not found\n" } func renderHome(page int) string { var b strings.Builder b.WriteString("# Gnomic OTC\n\n") b.WriteString("Escrowed GRC20 ⇄ GNOT offers, filled in full and atomically.\n\n") if paused { b.WriteString("**Paused:** no new offers or fills. Makers can still cancel.\n\n") } b.WriteString("| Volume | Fees | Filled | Open (public) | Fee |\n|---|---|---|---|---|\n") b.WriteString("| " + units(totalVolume, 6) + " GNOT | " + units(totalFees, 6) + " GNOT | " + num(filledCount) + " | " + strconv.Itoa(openPublic.Size()) + " | " + bps(feeBps)) if gnomicFeeOn { b.WriteString(" (GNOMIC " + bps(gnomicFee) + ")") } b.WriteString(" |\n\n") b.WriteString("## Open offers\n\n") size := openPublic.Size() // Compared as page numbers, not as an offset: (page-1)*pageSize overflows // for a large enough page. if page > 1 && page-1 > (size-1)/defaultPageSize { return "# Not found\n" } if size == 0 { b.WriteString("No open offers.\n") return b.String() } offset := (page - 1) * defaultPageSize b.WriteString("| ID | Side | Token | Amount | GNOT | Maker | Expires |\n|---|---|---|---|---|---|---|\n") openPublic.ReverseIterateByOffset(offset, defaultPageSize, func(key string, _ any) bool { o := offers.Get(key).(*Offer) sym, dec := tokenInfo(o.TokenKey) b.WriteString("| [" + num64(o.ID) + "](" + link("offer/"+num64(o.ID)) + ") | " + o.Side.String() + " | " + sym + " | " + units(o.TokenAmt, dec) + " | " + units(o.GnotAmt, 6) + " | " + short(o.Maker) + " | " + expiry(o) + " |\n") return false }) if page > 1 { b.WriteString("\n[← newer](" + link("page/"+strconv.Itoa(page-1)) + ")") } if offset+defaultPageSize < size { b.WriteString("\n[older →](" + link("page/"+strconv.Itoa(page+1)) + ")") } b.WriteString("\n") return b.String() } func renderOffer(o *Offer) string { sym, dec := tokenInfo(o.TokenKey) fee := feeOf(o.GnotAmt, o.FeeBps) var b strings.Builder b.WriteString("# Offer " + num64(o.ID) + "\n\n") b.WriteString("| | |\n|---|---|\n") if o.Status == Open && expired(o) { b.WriteString("| Status | Open, expired: it can no longer be filled, only cancelled by its maker |\n") } else { b.WriteString("| Status | " + o.Status.String() + " |\n") } b.WriteString("| Side | " + o.Side.String() + " |\n") b.WriteString("| Token | " + sym + " (`" + o.TokenKey + "`) |\n") b.WriteString("| Token amount | " + units(o.TokenAmt, dec) + " " + sym + " |\n") b.WriteString("| GNOT amount | " + units(o.GnotAmt, 6) + " GNOT |\n") b.WriteString("| Fee | " + bps(o.FeeBps) + " = " + units(fee, 6) + " GNOT, paid by whoever receives the GNOT |\n") b.WriteString("| Maker | `" + o.Maker.String() + "` |\n") if o.Taker == "" { b.WriteString("| Taker | anyone |\n") } else { b.WriteString("| Taker | `" + o.Taker.String() + "` (not listed) |\n") } b.WriteString("| Created | " + when(o.CreatedAt) + " |\n") b.WriteString("| Expires | " + expiry(o) + " |\n") b.WriteString("\n[← all offers](" + link("") + ")\n") return b.String() } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- func listJSON(t *avl.Tree, offset, limit int) string { if offset < 0 { offset = 0 } if limit <= 0 { limit = defaultPageSize } if limit > maxPageSize { limit = maxPageSize } var b strings.Builder b.WriteString(`{"total":` + strconv.Itoa(t.Size()) + `,"offers":[`) i := 0 t.ReverseIterateByOffset(offset, limit, func(key string, _ any) bool { if i > 0 { b.WriteString(",") } i++ b.WriteString(offerJSON(offers.Get(key).(*Offer))) return false }) b.WriteString("]}") return b.String() } func offerJSON(o *Offer) string { sym, dec := tokenInfo(o.TokenKey) return `{"id":` + jstr(num64(o.ID)) + `,"maker":` + jstr(o.Maker.String()) + `,"taker":` + jstr(o.Taker.String()) + `,"side":` + jstr(o.Side.String()) + `,"token_key":` + jstr(o.TokenKey) + `,"token_symbol":` + jstr(sym) + `,"token_decimals":` + strconv.Itoa(dec) + `,"token_amt":` + jstr(num(o.TokenAmt)) + `,"gnot_amt":` + jstr(num(o.GnotAmt)) + `,"fee_bps":` + num(o.FeeBps) + `,"fee_ugnot":` + jstr(num(feeOf(o.GnotAmt, o.FeeBps))) + `,"expires_at":` + num(o.ExpiresAt) + `,"expired":` + strconv.FormatBool(expired(o)) + `,"created_at":` + num(o.CreatedAt) + `,"status":` + jstr(o.Status.String()) + `}` } func tokenInfo(key string) (string, int) { t := grc20reg.Get(key) if t == nil { return key, 0 } return t.GetSymbol(), t.GetDecimals() } // jstr quotes a string for JSON. func jstr(s string) string { var b strings.Builder b.WriteByte('"') for _, r := range s { switch { case r == '"' || r == '\\': b.WriteByte('\\') b.WriteRune(r) case r < 0x20: h := strconv.FormatInt(int64(r), 16) b.WriteString(`\u` + strings.Repeat("0", 4-len(h)) + h) default: b.WriteRune(r) } } b.WriteByte('"') return b.String() } func num(v int64) string { return strconv.FormatInt(v, 10) } func num64(v uint64) string { return strconv.FormatUint(v, 10) } // units formats base units with dec decimals, trailing zeros dropped. func units(v int64, dec int) string { s := strconv.FormatInt(v, 10) if dec <= 0 { return s } if len(s) <= dec { s = strings.Repeat("0", dec-len(s)+1) + s } whole, frac := s[:len(s)-dec], strings.TrimRight(s[len(s)-dec:], "0") if frac == "" { return whole } return whole + "." + frac } func bps(v int64) string { return units(v, 2) + "%" } func short(a address) string { s := a.String() if len(s) <= 12 { return s } return s[:8] + "…" + s[len(s)-4:] } // link is an absolute gnoweb link to one of this realm's Render paths. // gnoweb rejects a bare ":path" destination and prints it as plain text. func link(path string) string { if path == "" { return webPath } return webPath + ":" + path } // expiry is the Expires cell: the date, marked once it has passed. func expiry(o *Offer) string { if expired(o) { return "expired " + when(o.ExpiresAt) } return when(o.ExpiresAt) } func when(unix int64) string { if unix == 0 { return "never" } return time.Unix(unix, 0).UTC().Format("2006-01-02 15:04 UTC") }
  14. #14types.gno
  15. #15package otc // Side says which half of the offer the maker escrowed. type Side int const ( SellToken Side = 1 // maker escrows the token, taker pays GNOT SellGnot Side = 2 // maker escrows GNOT, taker pays the token ) func (s Side) String() string { switch s { case SellToken: return "SellToken" case SellGnot: return "SellGnot" } return "unknown" } // Status is Open until the offer is filled or cancelled. Both are terminal // (R12). type Status int const ( Open Status = 1 Filled Status = 2 Cancelled Status = 3 ) func (s Status) String() string { switch s { case Open: return "Open" case Filled: return "Filled" case Cancelled: return "Cancelled" } return "unknown" } // Offer is one escrowed trade: exactly one GRC20 side and one GNOT side (R1). type Offer struct { ID uint64 Maker address Taker address // empty = public (R4), set = private (R5) Side Side TokenKey string // grc20reg key (W3) TokenAmt int64 // base units of the token GnotAmt int64 // ugnot FeeBps int64 // copied at creation, never changed (F4, I4) ExpiresAt int64 // unix seconds, 0 = none (R9) CreatedAt int64 Status Status } // Errors are short and stable so that tests and the frontend can match them. const ( ErrPaused = "otc: paused" ErrNotUser = "otc: must be a direct user call" ErrNoCoins = "otc: this call takes no coins" ErrBadPayment = "otc: send exactly the required ugnot and nothing else" ErrBadAmount = "otc: amounts must be positive" ErrBadExpiry = "otc: expiry must be 0 or in the future" ErrBadAddress = "otc: invalid address" ErrTakerIsMaker = "otc: taker cannot be the maker" ErrTokenNotListed = "otc: token not whitelisted" ErrTokenDisabled = "otc: token disabled" ErrTokenUnknown = "otc: token not in grc20reg" ErrTokenExists = "otc: token already whitelisted" ErrTooManyTokens = "otc: token whitelist is full" ErrNoOffer = "otc: no such offer" ErrNotOpen = "otc: offer is not open" ErrNotMaker = "otc: not maker" ErrNotTaker = "otc: offer is reserved for another taker" ErrOwnOffer = "otc: maker cannot fill own offer" ErrExpired = "otc: offer expired" ErrNotAdmin = "otc: not admin" ErrFeeAboveCap = "otc: fee above cap" ErrFeeToRealm = "otc: fee recipient cannot be the realm" )
  16. #16/gno.MemPackageType
  17. #17 MPUserAll

Result log

msg:0,success:true,log:,events:[]

← Back to block 380,568