dutchauction.gno
7.45 Kb · 257 lines
1// Package dutchauction ports the classic Solidity Dutch-auction pattern to
2// gno.land: a seller lists an item at a high starting price that decays
3// linearly, block by block, down to a floor price. The first buyer to call
4// Buy pays whatever the price is at that moment — real ugnot, escrowed by
5// the chain with the transaction and forwarded to the seller on settlement.
6// Overpayment is refunded automatically. State-mutating functions are
7// crossing functions (`cur realm`) per the gno 0.9 interrealm convention.
8package dutchauction
9
10import (
11 "errors"
12 "strconv"
13 "strings"
14
15 "chain"
16 "chain/banker"
17 "chain/runtime"
18 "chain/runtime/unsafe"
19
20 "gno.land/p/nt/avl/v0"
21)
22
23const denom = "ugnot"
24
25type status int
26
27const (
28 statusActive status = iota
29 statusSold
30 statusCancelled
31)
32
33func (s status) String() string {
34 switch s {
35 case statusActive:
36 return "active"
37 case statusSold:
38 return "sold"
39 case statusCancelled:
40 return "cancelled"
41 default:
42 return "unknown"
43 }
44}
45
46// auction is one listing: price falls linearly from StartPrice to
47// FloorPrice over Duration blocks starting at StartHeight, then holds flat
48// at FloorPrice until bought or cancelled.
49type auction struct {
50 ID string
51 Item string
52 Seller address
53 StartPrice int64
54 FloorPrice int64
55 StartHeight int64
56 Duration int64
57 Status status
58 Buyer address
59 SoldPrice int64
60}
61
62var (
63 auctions avl.Tree // id string -> *auction
64 nextID uint64
65 totalListed uint64
66 totalSold uint64
67 totalVolume int64
68)
69
70var (
71 errEmptyItem = errors.New("dutchauction: item description required")
72 errBadPrice = errors.New("dutchauction: startPrice must be greater than floorPrice, floorPrice must be >= 0")
73 errBadDuration = errors.New("dutchauction: durationBlocks must be positive")
74 errNotFound = errors.New("dutchauction: auction not found")
75 errNotActive = errors.New("dutchauction: auction is not active")
76 errNotSeller = errors.New("dutchauction: caller is not the seller")
77 errNotUserCall = errors.New("dutchauction: buy must be a direct user transaction")
78 errUnderpaid = errors.New("dutchauction: payment below current price")
79 errSpoofedRealm = errors.New("dutchauction: spoofed realm")
80)
81
82// caller authenticates the crossing frame and returns the immediate caller.
83func caller(cur realm) address {
84 if !cur.IsCurrent() {
85 panic(errSpoofedRealm)
86 }
87 return cur.Previous().Address()
88}
89
90// List creates a new Dutch auction for item, starting at startPrice ugnot
91// and falling linearly to floorPrice over durationBlocks. Returns the new
92// auction's ID. Crossing function.
93func List(cur realm, item string, startPrice, floorPrice, durationBlocks int64) string {
94 seller := caller(cur)
95
96 item = strings.TrimSpace(item)
97 if item == "" {
98 panic(errEmptyItem)
99 }
100 if floorPrice < 0 || startPrice <= floorPrice {
101 panic(errBadPrice)
102 }
103 if durationBlocks <= 0 {
104 panic(errBadDuration)
105 }
106
107 nextID++
108 id := strconv.FormatUint(nextID, 10)
109 auctions.Set(id, &auction{
110 ID: id,
111 Item: item,
112 Seller: seller,
113 StartPrice: startPrice,
114 FloorPrice: floorPrice,
115 StartHeight: runtime.ChainHeight(),
116 Duration: durationBlocks,
117 Status: statusActive,
118 })
119 totalListed++
120 return id
121}
122
123// Buy purchases auction id at its current price. The caller must send at
124// least that many ugnot with the transaction; any excess is refunded
125// immediately. Only a direct EOA transaction (MsgCall) may buy, so the
126// payment envelope can't be spoofed via an ephemeral MsgRun realm. Returns
127// the price actually paid. Crossing function.
128func Buy(cur realm, id string) int64 {
129 if !cur.Previous().IsUserCall() {
130 panic(errNotUserCall)
131 }
132 buyer := cur.Previous().Address()
133
134 a := getAuction(id)
135 if a.Status != statusActive {
136 panic(errNotActive)
137 }
138
139 price := priceAt(a, runtime.ChainHeight())
140 sent := unsafe.OriginSend()
141 paid := sent.AmountOf(denom)
142 if paid < price {
143 panic(errUnderpaid)
144 }
145
146 a.Status = statusSold
147 a.Buyer = buyer
148 a.SoldPrice = price
149 totalSold++
150 totalVolume += price
151
152 bnk := banker.NewBanker(banker.BankerTypeOriginSend, cur)
153 pkgAddr := cur.Address()
154 if paid > price {
155 bnk.SendCoins(pkgAddr, buyer, chain.NewCoins(chain.NewCoin(denom, paid-price)))
156 }
157 bnk.SendCoins(pkgAddr, a.Seller, chain.NewCoins(chain.NewCoin(denom, price)))
158
159 return price
160}
161
162// Cancel withdraws an active auction. Only the seller may cancel, and only
163// before it's bought. Crossing function.
164func Cancel(cur realm, id string) {
165 who := caller(cur)
166
167 a := getAuction(id)
168 if a.Status != statusActive {
169 panic(errNotActive)
170 }
171 if who != a.Seller {
172 panic(errNotSeller)
173 }
174 a.Status = statusCancelled
175}
176
177// CurrentPrice returns id's price at the current chain height.
178func CurrentPrice(id string) int64 {
179 return priceAt(getAuction(id), runtime.ChainHeight())
180}
181
182// AuctionInfo returns a snapshot of auction id.
183func AuctionInfo(id string) (item string, seller address, status string, currentPrice int64, floorPrice int64, startPrice int64) {
184 a := getAuction(id)
185 return a.Item, a.Seller, a.Status.String(), priceAt(a, runtime.ChainHeight()), a.FloorPrice, a.StartPrice
186}
187
188// priceAt computes the linear-decay price of a at the given height. Once
189// settled (sold or cancelled), the price is frozen: SoldPrice for a sale,
190// zero for a cancellation.
191func priceAt(a *auction, height int64) int64 {
192 if a.Status != statusActive {
193 return a.SoldPrice
194 }
195 elapsed := height - a.StartHeight
196 if elapsed <= 0 {
197 return a.StartPrice
198 }
199 if elapsed >= a.Duration {
200 return a.FloorPrice
201 }
202 drop := a.StartPrice - a.FloorPrice
203 return a.StartPrice - drop*elapsed/a.Duration
204}
205
206func getAuction(id string) *auction {
207 v := auctions.Get(id)
208 if v == nil {
209 panic(errNotFound)
210 }
211 return v.(*auction)
212}
213
214// Render produces the gnoweb Markdown view: a table of every auction,
215// newest first, with live prices. Not a crossing function.
216func Render(path string) string {
217 var b strings.Builder
218 b.WriteString("# Dutch Auction\n\n")
219 b.WriteString("A price that only ever falls. List an item high; it decays block by ")
220 b.WriteString("block toward a floor; the first `Buy` call wins it at whatever the ")
221 b.WriteString("price is at that instant. Real ugnot, escrowed by the chain and paid ")
222 b.WriteString("straight to the seller — overpayment is refunded automatically.\n\n")
223
224 b.WriteString("- **Total listed:** " + strconv.FormatUint(totalListed, 10) + "\n")
225 b.WriteString("- **Total sold:** " + strconv.FormatUint(totalSold, 10) + "\n")
226 b.WriteString("- **Total volume:** " + strconv.FormatInt(totalVolume, 10) + " ugnot\n")
227 b.WriteString("- **Current height:** " + strconv.FormatInt(runtime.ChainHeight(), 10) + "\n\n")
228
229 if auctions.Size() == 0 {
230 b.WriteString("_No auctions yet — call `List` to open one._\n")
231 return b.String()
232 }
233
234 b.WriteString("## Auctions\n\n")
235 b.WriteString("| ID | Item | Status | Price now | Floor | Start | Seller |\n")
236 b.WriteString("| ---: | --- | --- | ---: | ---: | ---: | --- |\n")
237
238 height := runtime.ChainHeight()
239 ids := make([]uint64, 0, auctions.Size())
240 auctions.Iterate("", "", func(key string, value any) bool {
241 n, _ := strconv.ParseUint(key, 10, 64)
242 ids = append(ids, n)
243 return false
244 })
245 for i := len(ids) - 1; i >= 0; i-- {
246 id := strconv.FormatUint(ids[i], 10)
247 v := auctions.Get(id)
248 a := v.(*auction)
249 b.WriteString("| " + a.ID + " | " + a.Item + " | " + a.Status.String() +
250 " | " + strconv.FormatInt(priceAt(a, height), 10) +
251 " | " + strconv.FormatInt(a.FloorPrice, 10) +
252 " | " + strconv.FormatInt(a.StartPrice, 10) +
253 " | `" + a.Seller.String() + "` |\n")
254 }
255
256 return b.String()
257}