dexprobe.gno
11.33 Kb · 288 lines
1// Package dexprobe is a one-shot feasibility probe: it exercises every GnoSwap
2// primitive an on-chain order engine (DCA / limit / stop-loss, keeper-driven,
3// non-custodial or vault-funded) would ever need, so the real build is derisked
4// in a single deploy. Each exported fn answers one unknown; a harness drives the
5// multi-tx cases. Throwaway test realm — no product code here.
6package dexprobe
7
8import (
9 "strconv"
10 "time"
11
12 "chain"
13 "chain/banker"
14
15 "gno.land/p/nt/avl/v0"
16
17 "gno.land/r/gnoland/wugnot"
18 "gno.land/r/gnoswap/gns"
19 "gno.land/r/gnoswap/pool"
20 "gno.land/r/gnoswap/router"
21)
22
23const (
24 WUG = "gno.land/r/gnoland/wugnot"
25 GNS = "gno.land/r/gnoswap/gns"
26 USDC = "gno.land/r/gnoswap/test_token/test_usdc"
27 DAI = "gno.land/r/gnoswap/test_token/test_dai"
28
29 poolWG = "gno.land/r/gnoland/wugnot:gno.land/r/gnoswap/gns:3000" // canonical, for pool reads
30 routeBuy = poolWG // WG -> GNS
31 routeSell = "gno.land/r/gnoswap/gns:gno.land/r/gnoland/wugnot:3000" // GNS -> WG
32 // 2-hop, no direct WG/DAI pool: WG -> USDC -> DAI
33 routeMulti = "gno.land/r/gnoland/wugnot:gno.land/r/gnoswap/test_token/test_usdc:3000*POOL*gno.land/r/gnoswap/test_token/test_usdc:gno.land/r/gnoswap/test_token/test_dai:3000"
34)
35
36var routerAddr = address("g1vc883gshu5z7ytk5cdynhc8c2dh67pdp4cszkp")
37
38var (
39 vault avl.Tree // owner -> int64 wugnot deposited (pre-funded custody model)
40 counter int // for the fail-atomicity probe
41)
42
43// ---- helpers ----
44func i64(n int64) string { return strconv.FormatInt(n, 10) }
45func atoi(s string) int64 { n, _ := strconv.ParseInt(s, 10, 64); return n }
46func dl() int64 { return time.Now().Unix() + 300 }
47
48// Fund lets the harness deposit native ugnot into this realm (for tip/native-send probes).
49func Fund(cur realm) {}
50
51// ============================================================ PRICE READS ====
52
53func SpotTick() int32 { return pool.GetSlot0Tick(poolWG) }
54func SpotSqrtPrice() string { return pool.GetSlot0SqrtPriceX96(poolWG) }
55
56// TwapProbe answers the #1 unknown: does a manipulation-resistant TWAP work?
57// Returns "tick=<t> liq=<l> err=<e>". On un-warmed pools err is non-nil.
58func TwapProbe(secondsAgo uint32) string {
59 t, l, err := pool.GetTWAP(poolWG, secondsAgo)
60 e := "nil"
61 if err != nil {
62 e = err.Error()
63 }
64 return "tick=" + i32(t) + " liq=" + l + " err=" + e
65}
66
67// Cardinality reports the pool's observation-buffer size (1 = un-warmed → no TWAP).
68func Cardinality() string {
69 os, err := pool.GetObservationState(poolWG)
70 if err != nil {
71 return "err=" + err.Error()
72 }
73 return "card=" + u16(os.Cardinality()) + " next=" + u16(os.CardinalityNext())
74}
75
76// GrowCardinality warms the TWAP buffer (Uniswap-v3 style). Crossing fn.
77func GrowCardinality(cur realm, cardNext uint16) {
78 pool.IncreaseObservationCardinalityNext(cross(cur), WUG, GNS, 3000, cardNext)
79 chain.Emit("GrewCardinality", "next", u16(cardNext))
80}
81
82// DryQuote calls GnoSwap's read-only quote FROM inside a realm (the slippage-floor
83// dependency). Returns the expected GNS out for amountIn wugnot.
84func DryQuote(amountIn int64) string {
85 _, out, ok := router.DrySwapRoute(WUG, GNS, i64(amountIn), "EXACT_IN", routeBuy, "100", "1")
86 return "out=" + out + " ok=" + boolS(ok)
87}
88
89// BestTier scans the fee tiers for the deepest pool of a pair (tier-selection).
90func BestTier(a, b string) string {
91 best := uint32(0)
92 bestLiq := int64(-1)
93 for _, fee := range []uint32{100, 500, 3000, 10000} {
94 for _, pp := range []string{a + ":" + b + ":" + u32(fee), b + ":" + a + ":" + u32(fee)} {
95 if pool.ExistsPoolPath(pp) {
96 liq := atoi(pool.GetLiquidity(pp))
97 if liq > bestLiq {
98 bestLiq, best = liq, fee
99 }
100 }
101 }
102 }
103 return "bestFee=" + u32(best) + " liq=" + i64(bestLiq)
104}
105
106// ============================================================== SWAPS ========
107// All pull the caller's input via allowance, swap as payer/recipient, forward output.
108
109// BuyGns: WG -> GNS (baseline, allowance-funded). Returns GNS out.
110func BuyGns(cur realm, amountIn, minOut int64) int64 {
111 u, self := cur.Previous().Address(), cur.Address()
112 wugnot.TransferFrom(cross(cur), u, self, amountIn)
113 wugnot.Approve(cross(cur), routerAddr, amountIn)
114 b := gns.BalanceOf(self)
115 router.ExactInSingleSwapRoute(cross(cur), WUG, GNS, i64(amountIn), routeBuy, i64(minOut), "0", dl(), "")
116 out := gns.BalanceOf(self) - b
117 gns.Transfer(cross(cur), u, out)
118 return out
119}
120
121// SellGns: GNS -> WG (proves the SELL side). Returns wugnot out.
122func SellGns(cur realm, amountGns, minOut int64) int64 {
123 u, self := cur.Previous().Address(), cur.Address()
124 gns.TransferFrom(cross(cur), u, self, amountGns)
125 gns.Approve(cross(cur), routerAddr, amountGns)
126 b := wugnot.BalanceOf(self)
127 router.ExactInSingleSwapRoute(cross(cur), GNS, WUG, i64(amountGns), routeSell, i64(minOut), "0", dl(), "")
128 out := wugnot.BalanceOf(self) - b
129 wugnot.Transfer(cross(cur), u, out)
130 return out
131}
132
133// SellGnsToNative: sell GNS, unwrap wugnot -> native ugnot, deliver via banker.
134func SellGnsToNative(cur realm, amountGns, minOut int64) int64 {
135 u, self := cur.Previous().Address(), cur.Address()
136 gns.TransferFrom(cross(cur), u, self, amountGns)
137 gns.Approve(cross(cur), routerAddr, amountGns)
138 b := wugnot.BalanceOf(self)
139 router.ExactInSingleSwapRoute(cross(cur), GNS, WUG, i64(amountGns), routeSell, i64(minOut), "0", dl(), "")
140 out := wugnot.BalanceOf(self) - b
141 wugnot.Withdraw(cross(cur), out) // burn realm's wugnot -> native ugnot to realm
142 bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
143 bnk.SendCoins(self, u, chain.Coins{chain.NewCoin("ugnot", out)})
144 return out
145}
146
147// BuyExactGns: ExactOut — buy exactly amountOut GNS, spending <= amountInMax wugnot,
148// refunding the unused wugnot. Returns actual wugnot spent.
149func BuyExactGns(cur realm, amountOut, amountInMax int64) int64 {
150 u, self := cur.Previous().Address(), cur.Address()
151 wugnot.TransferFrom(cross(cur), u, self, amountInMax)
152 wugnot.Approve(cross(cur), routerAddr, amountInMax)
153 gb := gns.BalanceOf(self)
154 inStr, _ := router.ExactOutSingleSwapRoute(cross(cur), WUG, GNS, i64(amountOut), routeBuy, i64(amountInMax), "0", dl(), "")
155 gns.Transfer(cross(cur), u, gns.BalanceOf(self)-gb) // deliver GNS
156 spent := atoi(inStr)
157 if refund := amountInMax - spent; refund > 0 {
158 wugnot.Transfer(cross(cur), u, refund) // refund unused wugnot
159 }
160 return spent
161}
162
163// MultiHopBuy: WG -> USDC -> DAI (2 hops). Returns DAI out (string, from the router).
164// DAI stays in the realm (no DAI import needed to prove multi-hop works).
165func MultiHopBuy(cur realm, amountIn, minOut int64) string {
166 u, self := cur.Previous().Address(), cur.Address()
167 wugnot.TransferFrom(cross(cur), u, self, amountIn)
168 wugnot.Approve(cross(cur), routerAddr, amountIn)
169 _, out := router.ExactInSwapRoute(cross(cur), WUG, DAI, i64(amountIn), routeMulti, "100", i64(minOut), dl(), "")
170 return out
171}
172
173// FloorBuy: the on-chain slippage-floor design. Quote GnoSwap for THIS size, derive
174// minOut = expected*(1-maxSlippageBps) ON-CHAIN, then swap. Keeper can't set the floor.
175func FloorBuy(cur realm, amountIn, maxSlippageBps int64) int64 {
176 u, self := cur.Previous().Address(), cur.Address()
177 _, expStr, ok := router.DrySwapRoute(WUG, GNS, i64(amountIn), "EXACT_IN", routeBuy, "100", "1")
178 if !ok {
179 panic("dry quote unfillable")
180 }
181 minOut := atoi(expStr) * (10000 - maxSlippageBps) / 10000
182 wugnot.TransferFrom(cross(cur), u, self, amountIn)
183 wugnot.Approve(cross(cur), routerAddr, amountIn)
184 b := gns.BalanceOf(self)
185 router.ExactInSingleSwapRoute(cross(cur), WUG, GNS, i64(amountIn), routeBuy, i64(minOut), "0", dl(), "")
186 out := gns.BalanceOf(self) - b
187 gns.Transfer(cross(cur), u, out)
188 chain.Emit("FloorBuy", "expected", expStr, "minOut", i64(minOut), "got", i64(out))
189 return out
190}
191
192// ========================================================= KEEPER ECON =======
193
194// TipKeeper pays the caller `amount` ugnot from the realm's native balance (the
195// permissionless-keeper payment mechanic). Realm must be pre-Funded.
196func TipKeeper(cur realm, amount int64) {
197 bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
198 bnk.SendCoins(cur.Address(), cur.Previous().Address(), chain.Coins{chain.NewCoin("ugnot", amount)})
199 chain.Emit("TipPaid", "keeper", cur.Previous().Address().String(), "amount", i64(amount))
200}
201
202// FeeSkimBuy buys GNS, then skims feeBps of the output to `treasury` (revenue model).
203func FeeSkimBuy(cur realm, amountIn, feeBps int64, treasury address) int64 {
204 u, self := cur.Previous().Address(), cur.Address()
205 wugnot.TransferFrom(cross(cur), u, self, amountIn)
206 wugnot.Approve(cross(cur), routerAddr, amountIn)
207 b := gns.BalanceOf(self)
208 router.ExactInSingleSwapRoute(cross(cur), WUG, GNS, i64(amountIn), routeBuy, "1", "0", dl(), "")
209 out := gns.BalanceOf(self) - b
210 fee := out * feeBps / 10000
211 if fee > 0 {
212 gns.Transfer(cross(cur), treasury, fee)
213 }
214 gns.Transfer(cross(cur), u, out-fee)
215 return fee
216}
217
218// ============================================================ VAULT ==========
219// Pre-funded custody model: owner deposits wugnot; keeper draws per fill (no allowance).
220
221func VaultDeposit(cur realm, amount int64) {
222 u, self := cur.Previous().Address(), cur.Address()
223 wugnot.TransferFrom(cross(cur), u, self, amount)
224 vault.Set(u.String(), vaultBal(u)+amount)
225 chain.Emit("VaultDeposit", "owner", u.String(), "amount", i64(amount))
226}
227
228func VaultBalance(owner address) int64 { return vaultBal(owner) }
229
230// VaultSwapBuy: keeper-callable, draws from the owner's vault balance (not allowance),
231// swaps, forwards GNS to owner. `owner` is an arg because the keeper (not owner) calls.
232func VaultSwapBuy(cur realm, owner address, amountIn, minOut int64) int64 {
233 self := cur.Address()
234 bal := vaultBal(owner)
235 if bal < amountIn {
236 panic("insufficient vault balance")
237 }
238 vault.Set(owner.String(), bal-amountIn) // STATE-BEFORE-SEND
239 wugnot.Approve(cross(cur), routerAddr, amountIn)
240 b := gns.BalanceOf(self)
241 router.ExactInSingleSwapRoute(cross(cur), WUG, GNS, i64(amountIn), routeBuy, i64(minOut), "0", dl(), "")
242 out := gns.BalanceOf(self) - b
243 gns.Transfer(cross(cur), owner, out)
244 return out
245}
246
247func VaultWithdraw(cur realm, amount int64) {
248 u := cur.Previous().Address()
249 bal := vaultBal(u)
250 if bal < amount {
251 panic("insufficient vault balance")
252 }
253 vault.Set(u.String(), bal-amount) // STATE-BEFORE-SEND
254 wugnot.Transfer(cross(cur), u, amount)
255}
256
257func vaultBal(owner address) int64 {
258 v, ok := vault.Get(owner.String())
259 if !ok {
260 return 0
261 }
262 return v.(int64)
263}
264
265// ======================================================= FAIL ATOMICITY ======
266
267func Counter() int { return counter }
268
269// FailAtomic bumps the counter then forces a swap revert (impossible minOut). The whole
270// tx must revert → Counter() stays unchanged, proving state rolls back on a failed fill.
271func FailAtomic(cur realm, amountIn int64) {
272 counter++
273 u, self := cur.Previous().Address(), cur.Address()
274 wugnot.TransferFrom(cross(cur), u, self, amountIn)
275 wugnot.Approve(cross(cur), routerAddr, amountIn)
276 router.ExactInSingleSwapRoute(cross(cur), WUG, GNS, i64(amountIn), routeBuy, i64(amountIn*1000000), "0", dl(), "")
277}
278
279// ---- fmt helpers ----
280func i32(n int32) string { return strconv.FormatInt(int64(n), 10) }
281func u16(n uint16) string { return strconv.FormatUint(uint64(n), 10) }
282func u32(n uint32) string { return strconv.FormatUint(uint64(n), 10) }
283func boolS(b bool) string { if b { return "true" }; return "false" }
284
285func Render(_ string) string {
286 return "# dexprobe (feasibility probe)\n\nLive tick: " + i32(SpotTick()) +
287 "\nCardinality: " + Cardinality() + "\nCounter: " + strconv.Itoa(counter) + "\n"
288}