orderbook.gno
7.96 Kb · 298 lines
1// Package orderbook is spike v2 of the GnoPulse on-chain order engine: it turns the
2// proven one-shot Execute(args) (see r/<addr>/limitspike) into a persistent order book —
3// CreateOrder stores an intent, a permissionless keeper pokes Execute(orderID), and the
4// realm RE-VALIDATES the trigger on-chain before swapping on GnoSwap for the owner.
5//
6// Two order kinds share one machine:
7// - "dca": time-gated. Executes `amountIn` wugnot every `intervalBlocks`, up to `runs` times.
8// - "limit": price-gated. Executes once when the pool tick >= minTick.
9//
10// Non-custodial: the realm never holds user funds. The owner grants THIS realm a wugnot
11// allowance; each fill pulls exactly `amountIn`, swaps wugnot->GNS, and forwards the GNS to
12// the owner. The keeper is trustless — it only controls TIMING; the realm re-checks the
13// trigger, so a keeper can never force an early or off-limit fill. Cancel = deactivate (or
14// the owner simply revokes the allowance).
15package orderbook
16
17import (
18 "strconv"
19 "time"
20
21 "chain"
22 "chain/runtime"
23
24 "gno.land/p/nt/avl/v0"
25
26 "gno.land/r/gnoland/wugnot"
27 "gno.land/r/gnoswap/gns"
28 "gno.land/r/gnoswap/pool"
29 "gno.land/r/gnoswap/router"
30)
31
32const (
33 inTok = "gno.land/r/gnoland/wugnot"
34 outTok = "gno.land/r/gnoswap/gns"
35 poolPath = "gno.land/r/gnoland/wugnot:gno.land/r/gnoswap/gns:3000"
36 routeArr = poolPath
37)
38
39// routerAddr is the GnoSwap router role address — the wugnot spender each fill approves.
40var routerAddr = address("g1vc883gshu5z7ytk5cdynhc8c2dh67pdp4cszkp")
41
42type Order struct {
43 ID uint64
44 Owner address
45 Kind string // "dca" | "limit"
46 AmountIn int64 // wugnot per fill
47 MinOut int64 // slippage floor per fill
48
49 MinTick int32 // limit trigger: fill when pool tick >= MinTick
50
51 IntervalBlocks int64 // dca trigger: min blocks between fills
52 NextRun int64 // earliest height for the next fill
53 RunsLeft int64 // fills remaining (limit orders start at 1)
54
55 CreatedAt int64
56 Active bool
57}
58
59var (
60 orders avl.Tree // pad(id) -> *Order
61 byOwner avl.Tree // owner:pad(id) -> true
62 nextID uint64
63 paused bool
64)
65
66// ---- helpers ----
67
68func pad(id uint64) string {
69 s := strconv.FormatInt(int64(id), 10)
70 for len(s) < 20 {
71 s = "0" + s
72 }
73 return s
74}
75
76func i64(n int64) string { return strconv.FormatInt(n, 10) }
77func i32(n int32) string { return strconv.FormatInt(int64(n), 10) }
78
79func boolStr(b bool) string {
80 if b {
81 return "true"
82 }
83 return "false"
84}
85
86func mustOrder(id uint64) *Order {
87 v, ok := orders.Get(pad(id))
88 if !ok {
89 panic("no such order")
90 }
91 return v.(*Order)
92}
93
94// ---- writes ----
95
96// CreateOrder stores an order intent and returns its id. The caller must first Approve
97// THIS realm as a wugnot spender for at least amountIn (for dca, amountIn*runs so every
98// fill is covered).
99// kind "dca": intervalBlocks>0 and runs>=1; minTick ignored; first fill is due immediately.
100// kind "limit": minTick set; runs forced to 1; intervalBlocks ignored.
101func CreateOrder(cur realm, kind string, amountIn, minOut int64, minTick int32, intervalBlocks, runs int64) uint64 {
102 if !cur.IsCurrent() {
103 panic("spoofed realm")
104 }
105 if paused {
106 panic("paused")
107 }
108 owner := cur.Previous().Address()
109 self := cur.Address()
110 if amountIn <= 0 {
111 panic("amountIn must be > 0")
112 }
113 if wugnot.Allowance(owner, self) < amountIn {
114 panic("approve this realm as a wugnot spender first (>= amountIn)")
115 }
116
117 o := &Order{
118 ID: nextID,
119 Owner: owner,
120 Kind: kind,
121 AmountIn: amountIn,
122 MinOut: minOut,
123 MinTick: minTick,
124 CreatedAt: runtime.ChainHeight(),
125 Active: true,
126 }
127 switch kind {
128 case "dca":
129 if intervalBlocks <= 0 || runs < 1 {
130 panic("dca needs intervalBlocks>0 and runs>=1")
131 }
132 o.IntervalBlocks = intervalBlocks
133 o.RunsLeft = runs
134 o.NextRun = runtime.ChainHeight() // first fill due now
135 case "limit":
136 o.RunsLeft = 1
137 default:
138 panic("kind must be dca or limit")
139 }
140
141 orders.Set(pad(nextID), o)
142 byOwner.Set(owner.String()+":"+pad(nextID), true)
143 chain.Emit("OrderCreated", "id", i64(int64(nextID)), "owner", owner.String(), "kind", kind)
144 nextID++
145 return o.ID
146}
147
148// CancelOrder deactivates an order. Owner only. No funds are held, so this just stops the
149// keeper from filling it (the owner may also revoke the wugnot allowance).
150func CancelOrder(cur realm, id uint64) {
151 if !cur.IsCurrent() {
152 panic("spoofed realm")
153 }
154 o := mustOrder(id)
155 if o.Owner != cur.Previous().Address() {
156 panic("not owner")
157 }
158 o.Active = false
159 chain.Emit("OrderCancelled", "id", i64(int64(id)))
160}
161
162// Execute runs one fill. PERMISSIONLESS — any keeper may poke it; the realm re-validates
163// the trigger on-chain, so a keeper controls only TIMING, never whether a fill is allowed.
164// Returns (tickAtExec, gnsOut).
165func Execute(cur realm, id uint64) (int32, int64) {
166 if !cur.IsCurrent() {
167 panic("spoofed realm")
168 }
169 if paused {
170 panic("paused")
171 }
172 o := mustOrder(id)
173 if !o.Active {
174 panic("order inactive")
175 }
176
177 tick := pool.GetSlot0Tick(poolPath)
178
179 // TRIGGER GATE — re-checked on-chain regardless of what the keeper believes.
180 switch o.Kind {
181 case "dca":
182 if runtime.ChainHeight() < o.NextRun {
183 panic("dca: not due (height " + i64(runtime.ChainHeight()) + " < nextRun " + i64(o.NextRun) + ")")
184 }
185 case "limit":
186 if tick < o.MinTick {
187 panic("limit: tick " + i32(tick) + " < minTick " + i32(o.MinTick))
188 }
189 }
190
191 // STATE-BEFORE-SEND: advance the schedule before any external call so a duplicate or
192 // re-entrant poke can't double-fill. A revert below rolls this back atomically.
193 o.RunsLeft--
194 if o.Kind == "dca" {
195 o.NextRun = runtime.ChainHeight() + o.IntervalBlocks
196 }
197 if o.RunsLeft <= 0 {
198 o.Active = false
199 }
200
201 self := cur.Address()
202
203 // Pull the owner's wugnot (owner pre-approved this realm), swap, forward GNS to owner.
204 wugnot.TransferFrom(cross(cur), o.Owner, self, o.AmountIn)
205 wugnot.Approve(cross(cur), routerAddr, o.AmountIn)
206
207 before := gns.BalanceOf(self)
208 router.ExactInSingleSwapRoute(
209 cross(cur),
210 inTok, outTok,
211 i64(o.AmountIn),
212 routeArr,
213 i64(o.MinOut),
214 "0",
215 time.Now().Unix()+300,
216 "",
217 )
218 out := gns.BalanceOf(self) - before
219 if out > 0 {
220 gns.Transfer(cross(cur), o.Owner, out)
221 }
222
223 chain.Emit("OrderFilled",
224 "id", i64(int64(id)),
225 "owner", o.Owner.String(),
226 "kind", o.Kind,
227 "tick", i32(tick),
228 "amountIn", i64(o.AmountIn),
229 "gnsOut", i64(out),
230 "runsLeft", i64(o.RunsLeft),
231 )
232 return tick, out
233}
234
235// ---- reads ----
236
237func Tick() int32 { return pool.GetSlot0Tick(poolPath) }
238
239func GetOrderJSON(id uint64) string { return orderJSON(mustOrder(id)) }
240
241// ListActiveJSON returns up to `limit` active orders from `offset` — the keeper's scan feed.
242func ListActiveJSON(offset, limit int) string {
243 out := "["
244 n := 0
245 seen := 0
246 orders.Iterate("", "", func(_ string, v any) bool {
247 o := v.(*Order)
248 if !o.Active {
249 return false
250 }
251 if seen >= offset && n < limit {
252 if n > 0 {
253 out += ","
254 }
255 out += orderJSON(o)
256 n++
257 }
258 seen++
259 return n >= limit
260 })
261 return out + "]"
262}
263
264func orderJSON(o *Order) string {
265 return "{\"id\":" + i64(int64(o.ID)) +
266 ",\"owner\":\"" + o.Owner.String() + "\"" +
267 ",\"kind\":\"" + o.Kind + "\"" +
268 ",\"amountIn\":" + i64(o.AmountIn) +
269 ",\"minOut\":" + i64(o.MinOut) +
270 ",\"minTick\":" + i32(o.MinTick) +
271 ",\"intervalBlocks\":" + i64(o.IntervalBlocks) +
272 ",\"nextRun\":" + i64(o.NextRun) +
273 ",\"runsLeft\":" + i64(o.RunsLeft) +
274 ",\"active\":" + boolStr(o.Active) + "}"
275}
276
277func Render(_ string) string {
278 out := "# GnoPulse Order Book (spike v2)\n\nLive pool tick: **" + i32(Tick()) + "**\n\n"
279 out += "| id | kind | owner | amtIn | runsLeft | gate | active |\n|--|--|--|--|--|--|--|\n"
280 orders.Iterate("", "", func(_ string, v any) bool {
281 o := v.(*Order)
282 gate := "t>=" + i32(o.MinTick)
283 if o.Kind == "dca" {
284 gate = "h>=" + i64(o.NextRun)
285 }
286 out += "| " + i64(int64(o.ID)) + " | " + o.Kind + " | " + short(o.Owner.String()) +
287 " | " + i64(o.AmountIn) + " | " + i64(o.RunsLeft) + " | " + gate + " | " + boolStr(o.Active) + " |\n"
288 return false
289 })
290 return out
291}
292
293func short(a string) string {
294 if len(a) <= 12 {
295 return a
296 }
297 return a[:8] + "…" + a[len(a)-4:]
298}