Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}