exchange_core.gno
13.76 Kb · 386 lines
1package game33
2
3// ── P2P exchange: core (docs/P2P_EXCHANGE_PLAN.md, docs/EXCHANGE_SECURITY_REVIEW.md)
4//
5// Open economy (Simion, 2026-10-11): no price-impact cap, no price bands, no
6// reference-price checks on deposits. Every value-moving call takes the
7// trader's own protection instead: a minimum output (minOut / minShares /
8// minGet) and a deadline.
9//
10// Security rules this file enforces for the whole exchange:
11// - The exchange only moves coins that already exist in `balances`
12// (debitBal → reserves/escrow → creditBal). It never mints.
13// - The admin has NO path to pool reserves, LP positions, escrowed orders,
14// accrued casino fees or player balances. Admin powers are listed in
15// docs/EXCHANGE_SECURITY_REVIEW.md §2.
16// - Pause stops only NEW swaps / orders / deposits. Exits — RemoveLiquidity,
17// CancelOrder, SweepExpiredOrders, WithdrawProtocolFees — always work,
18// also while paused and for banned addresses.
19// - All amounts are uint64; every add/mul is checked (panics, never wraps);
20// products use 128-bit intermediates; rounding favors the pool / maker.
21// - No exchange path calls another realm or sends coins: no reentrancy.
22// - Loops are bounded by constants (≤ maxFillsPerTx fills, ≤ maxScanPerTx
23// scanned entries, ≤ maxViewLimit view rows, ≤ maxOpenOrders per maker).
24// - Block time is used only for deadlines, order expiry and the LP warm-up.
25
26import (
27 "math/bits"
28 "strconv"
29 "time"
30
31 "chain"
32 "chain/banker"
33
34 "gno.land/p/nt/avl/v0"
35)
36
37const (
38 hubCoin = "grin"
39
40 // Pools: 0.50% of the input; 0.35% stays in the pool (LPs), 0.15% is the
41 // protocol fee, split 50/50 between the admin and the casino pool.
42 poolFeeBps = uint64(50)
43 protoFeeBps = uint64(15)
44
45 minLiquidity = uint64(1000) // first-deposit shares burned forever
46 minOpenDepthGrin = uint64(2000) // a pool takes swaps only once rGrin ≥ this
47 warmupSecs = int64(600) // LP fee warm-up (principal always instant)
48 rootKScale = uint64(1000000000)
49
50 // Order book: takers 0.50% of what they pay; makers 0.90% of what they
51 // receive (deducted from the maker's proceeds at fill time, never upfront).
52 // No flat posting fee — any amount meeting the size floor can be listed.
53 takerFeeBps = uint64(50)
54 makerFeeBps = uint64(90)
55 minOrderGrinX1k = uint64(50000) // 50 grin of value (reference table) — size floor only
56 maxOpenOrders = uint64(20)
57 defaultExpiry = int64(72 * 3600)
58 maxExpiry = int64(7 * 24 * 3600)
59 maxFillsPerTx = 10
60 maxScanPerTx = 20
61 maxViewLimit = 50
62
63 // Upper bound for any single amount argument. Keeps every intermediate far
64 // from uint64 limits (balances are uint64 too) and rejects absurd inputs.
65 maxAmount = uint64(1000000000000000) // 1e15
66
67)
68
69// Reference value per rarity in milli-grin (RARITY_VALUE ×1000). Used ONLY
70// for the 50-grin minimum order size — never for prices.
71var prefMilli = [7]uint64{1000, 3200, 11000, 40000, 160000, 720000, 3600000}
72
73var (
74 pools avl.Tree // coin → *pool
75 lpPositions avl.Tree // coin+":"+addr → *lpPos
76 adminFees avl.Tree // coin → admin's accrued protocol fees (pull)
77 casinoPool avl.Tree // coin → casino pool balance (fees in, capped jackpots out)
78 makerBooks avl.Tree // maker → ";"-joined compact order records (≤ maxOpenOrders)
79 sides avl.Tree // want+">"+give → sorted fixed-width entries (≤ maxSideOrders)
80 openOrderTotal uint64
81 nextOrderId uint64
82 exPaused bool
83
84 // Lifetime accounting (Render + audits).
85 adminFeesWithdrawn avl.Tree // coin → lifetime withdrawn by the admin
86 adminGnotWithdrawn int64 // ugnot withdrawn by WithdrawFunds (≤ passRevenueUgnot)
87)
88
89// ── checked math ─────────────────────────────────────────────────────────────
90
91func addChk(a, b uint64) uint64 {
92 s, carry := bits.Add64(a, b, 0)
93 if carry != 0 {
94 panic("math overflow")
95 }
96 return s
97}
98
99func subChk(a, b uint64) uint64 {
100 if b > a {
101 panic("math underflow")
102 }
103 return a - b
104}
105
106// mulDiv = floor(a*b/c) with a 128-bit product; panics if c == 0 or the
107// quotient does not fit in 64 bits.
108func mulDiv(a, b, c uint64) uint64 {
109 if c == 0 {
110 panic("division by zero")
111 }
112 hi, lo := bits.Mul64(a, b)
113 if hi >= c {
114 panic("math overflow")
115 }
116 q, _ := bits.Div64(hi, lo, c)
117 return q
118}
119
120// mulDivUp = ceil(a*b/c).
121func mulDivUp(a, b, c uint64) uint64 {
122 if c == 0 {
123 panic("division by zero")
124 }
125 hi, lo := bits.Mul64(a, b)
126 if hi >= c {
127 panic("math overflow")
128 }
129 q, r := bits.Div64(hi, lo, c)
130 if r > 0 {
131 q = addChk(q, 1)
132 }
133 return q
134}
135
136// isqrt128 = floor(sqrt(a*b)) for the 128-bit product (Newton, bounded).
137func isqrt128(a, b uint64) uint64 {
138 hi, lo := bits.Mul64(a, b)
139 if hi == 0 {
140 return isqrt(lo)
141 }
142 // sqrt of a 128-bit number < 2^64. Start above the root and descend.
143 x := ^uint64(0)
144 for i := 0; i < 200; i++ {
145 if hi >= x { // product/x overflows ⇒ x below the root; cannot happen when descending from max
146 panic("isqrt: bad state")
147 }
148 q, _ := bits.Div64(hi, lo, x)
149 nx := (x >> 1) + (q >> 1) + (x & q & 1)
150 if nx >= x {
151 return x
152 }
153 x = nx
154 }
155 panic("isqrt: no convergence")
156}
157
158func isqrt(n uint64) uint64 {
159 if n < 2 {
160 return n
161 }
162 x := n
163 y := x/2 + 1
164 for i := 0; i < 200 && y < x; i++ {
165 x = y
166 y = (x + n/x) / 2
167 }
168 return x
169}
170
171// mulLess reports a*b < c*d using 128-bit products.
172func mulLess(a, b, c, d uint64) bool {
173 h1, l1 := bits.Mul64(a, b)
174 h2, l2 := bits.Mul64(c, d)
175 return h1 < h2 || (h1 == h2 && l1 < l2)
176}
177
178// requireBalance checks a balance before any write, so a failing call
179// changes nothing (validate-then-write).
180func requireBalance(addr, coin string, amt uint64) {
181 if have := balOf(addr, coin); have < amt {
182 panic("insufficient " + coin + ": need " + u(amt) + " have " + u(have))
183 }
184}
185
186func requireAmount(name string, v uint64) {
187 if v == 0 || v > maxAmount {
188 panic(name + " must be between 1 and " + strconv.FormatUint(maxAmount, 10))
189 }
190}
191
192func u(n uint64) string { return strconv.FormatUint(n, 10) }
193
194// ── gates ────────────────────────────────────────────────────────────────────
195
196// exchangeCaller returns the caller for exchange writes. Only end users (an
197// EOA MsgCall or the user's own MsgRun) may trade: other realms can't act
198// through the exchange, so there is no cross-realm call surface at all.
199func exchangeCaller(cur realm) string {
200 prev := cur.Previous()
201 if prev.PkgPath() != "" {
202 panic("exchange calls must come directly from a user")
203 }
204 return prev.Address().String()
205}
206
207// requireMarketOpen gates NEW activity (swaps, deposits, orders, fills).
208// Trading opens only after FinalizeMigration: MigrateBatch SETs balances, so
209// trading before that could duplicate or lose escrowed coins.
210func requireMarketOpen(caller string) {
211 if !migrationFinalized {
212 panic("exchange opens after FinalizeMigration")
213 }
214 if exPaused {
215 panic("exchange paused: new trades are off; unstake and cancel still work")
216 }
217 if isBlacklisted(caller) {
218 panic("address banned")
219 }
220}
221
222func requireDeadline(deadline int64) {
223 if deadline <= 0 {
224 panic("deadline required (unix seconds)")
225 }
226 if time.Now().Unix() > deadline {
227 panic("deadline passed")
228 }
229}
230
231func requireCoin(coin string) int {
232 r, ok := coinRarity[coin]
233 if !ok {
234 panic("unknown coin id: " + coin)
235 }
236 return r
237}
238
239// Trading is open to every account (Simion, 2026-10-11): no tool requirement
240// to swap, post, fill or stake. Anti-spam comes from the 50-grin order
241// minimum, the 2-grin post fee, the 0.5% taker/pool fees, the 20-open-orders
242// limit per maker and the 2,000-grin minimum pool depth.
243
244// No receive cap (Simion, 2026-10-11): every coin is freely transferable and
245// tradable, whatever tools the receiver owns.
246
247// ── protocol fees ────────────────────────────────────────────────────────────
248
249// accrueProtocolFee splits amt of coin 50/50: the floor half to the admin's
250// fee ledger, the rest to the casino pool. Both stay inside the realm.
251func accrueProtocolFee(coin string, amt uint64) {
252 if amt == 0 {
253 return
254 }
255 half := amt / 2
256 if half > 0 {
257 setU(&adminFees, coin, addChk(getU(&adminFees, coin), half))
258 }
259 setU(&casinoPool, coin, addChk(getU(&casinoPool, coin), amt-half))
260 casStat(coin, "", "fees", amt-half)
261 chain.Emit("ProtocolFeesAccrued", "coin", coin, "admin", u(half), "casino", u(amt-half))
262}
263
264// WithdrawProtocolFees moves the admin's OWN accrued fees for `coin` into the
265// admin's balance. It reads only adminFees (never reserves, escrow, the
266// casino pool or anyone's balance) and works while paused.
267func WithdrawProtocolFees(cur realm, coin string) uint64 {
268 requireAdmin(cur)
269 requireCoin(coin)
270 amt := getU(&adminFees, coin)
271 if amt == 0 {
272 panic("no fees to withdraw for " + coin)
273 }
274 adminFees.Remove(coin)
275 setU(&adminFeesWithdrawn, coin, addChk(getU(&adminFeesWithdrawn, coin), amt))
276 to := admin.String()
277 creditBal(to, coin, amt)
278 chain.Emit("ProtocolFeesWithdrawn", "coin", coin, "to", to, "amount", u(amt))
279 return amt
280}
281
282// ── casino pool (fee share + losing stakes in, wins out; never minted) ─────
283//
284// casinoPool[coin] total pool, including the reserved part
285// casinoReserved[coin] sum of potential net payouts of unsettled bets
286// casinoDay[coin] "day:snapshot:paid:received" for the current UTC day
287//
288// A bet is accepted only if the pool can pay it in full: its potential net
289// payout (= the stake, since a win pays 2×) is reserved at placement, so a
290// reveal never pays partially and the pool can't go below zero.
291
292
293
294
295
296
297
298
299
300// ── admin controls (launch only; none touch user funds) ──────────────────────
301
302// SetExchangePaused stops NEW swaps, deposits, orders and fills. It can't
303// stop RemoveLiquidity, CancelOrder, SweepExpiredOrders or fee withdrawals.
304func SetExchangePaused(cur realm, p bool) {
305 requireAdmin(cur)
306 exPaused = p
307 chain.Emit("ExchangePaused", "paused", strconv.FormatBool(p))
308}
309
310func ExchangeStatus() string {
311 return `{"open":` + strconv.FormatBool(migrationFinalized && !exPaused) +
312 `,"paused":` + strconv.FormatBool(exPaused) +
313 `,"finalized":` + strconv.FormatBool(migrationFinalized) +
314 `,"pools":` + strconv.Itoa(pools.Size()) +
315 `,"openOrders":` + strconv.FormatUint(openOrderTotal, 10) +
316 `,"nextOrderId":"` + u(nextOrderId) + `"` +
317 `,"poolFeeBps":` + u(poolFeeBps) + `,"protoFeeBps":` + u(protoFeeBps) +
318 `,"takerFeeBps":` + u(takerFeeBps) + `,"makerFeeBps":` + u(makerFeeBps) +
319 `,"postFeeGrin":0` +
320 `,"minOpenDepthGrin":` + u(minOpenDepthGrin) + `,"warmupSecs":` + strconv.FormatInt(warmupSecs, 10) + `}`
321}
322
323func ProtocolFees(coin string) string {
324 return `{"coin":` + jsonStr(coin) + `,"admin":"` + u(getU(&adminFees, coin)) +
325 `","adminWithdrawn":"` + u(getU(&adminFeesWithdrawn, coin)) +
326 `","casino":"` + u(getU(&casinoPool, coin)) + `"}`
327}
328
329// ── GNOT bridge ───────────────────────────────────────────────────────────────
330//
331// DepositGnot and WithdrawGnot are the ONLY two places where ugnot crosses
332// between the Gno banker and the exchange's internal balance system.
333//
334// Security:
335// - DepositGnot requires a direct EOA call (exchangeCaller panics for
336// cross-realm calls) so no intermediate realm can deposit on a player's behalf.
337// - WithdrawGnot is intentionally NOT gated by requireMarketOpen: like
338// RemoveLiquidity and CancelOrder, withdrawals must always work even while
339// the exchange is paused or the caller is banned — it's the player's own money.
340// - The realm can only SendCoins ugnot it actually holds; the debit comes
341// first so if accounting ever drifts, the debit panics before any send.
342// - WithdrawFunds (admin pass revenue) remains completely isolated:
343// WithdrawGnot debits from the player's internal "gnot" balance, which is
344// a separate accounting entry from passRevenueUgnot. The two pools of ugnot
345// cannot mix via any code path.
346
347// DepositGnot wraps the ugnot attached to this MsgCall into the exchange's
348// internal "gnot" balance, enabling the player to trade or provide GNOT
349// liquidity. Attach the desired ugnot amount to the MsgCall as a send value.
350func DepositGnot(cur realm) uint64 {
351 caller := exchangeCaller(cur)
352 sent := banker.NewBanker(banker.BankerTypeOriginSend, cur).GetCoins(cur.Address())
353 amt := uint64(0)
354 for _, c := range sent {
355 if c.Denom == "ugnot" {
356 amt = uint64(c.Amount)
357 }
358 }
359 if amt == 0 {
360 panic("attach ugnot to deposit (MsgCall send field)")
361 }
362 gnotDeposited = addChk(gnotDeposited, amt)
363 creditBal(caller, gnotCoinId, amt)
364 chain.Emit("GnotDeposited", "who", caller, "amount", u(amt))
365 return amt
366}
367
368// WithdrawGnot converts internal "gnot" balance back to real ugnot and sends
369// it to the caller. Works while the exchange is paused — it is an exit, not a
370// trade. Any player can call this for their own balance at any time.
371func WithdrawGnot(cur realm, amtStr string) {
372 caller := exchangeCaller(cur)
373 amt, err := strconv.ParseUint(amtStr, 10, 64)
374 if err != nil || amt == 0 || amt > maxAmount {
375 panic("invalid amount")
376 }
377 requireBalance(caller, gnotCoinId, amt)
378 // Debit first — if the send ever panics, the player keeps their internal
379 // balance intact (they can retry). The realm holds the actual ugnot so
380 // the send cannot fail due to insufficient realm funds as long as
381 // gnotDeposited accounting is correct.
382 debitBal(caller, gnotCoinId, amt)
383 b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
384 b.SendCoins(cur.Address(), address(caller), chain.Coins{chain.NewCoin("ugnot", int64(amt))})
385 chain.Emit("GnotWithdrawn", "who", caller, "amount", u(amt))
386}