exchange_core.gno
10.75 Kb · 323 lines
1package game29
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
33 "gno.land/p/nt/avl/v0"
34)
35
36const (
37 hubCoin = "grin"
38
39 // Pools: 0.50% of the input; 0.35% stays in the pool (LPs), 0.15% is the
40 // protocol fee, split 50/50 between the admin and the casino pool.
41 poolFeeBps = uint64(50)
42 protoFeeBps = uint64(15)
43
44 minLiquidity = uint64(1000) // first-deposit shares burned forever
45 minOpenDepthGrin = uint64(2000) // a pool takes swaps only once rGrin ≥ this
46 warmupSecs = int64(600) // LP fee warm-up (principal always instant)
47 rootKScale = uint64(1000000000)
48
49 // Order book: makers 0%, takers 0.50% of what they pay, 2 grin per order.
50 takerFeeBps = uint64(50)
51 postFeeGrin = uint64(2)
52 minOrderGrinX1k = uint64(50000) // 50 grin of value (reference table) — size floor only
53 maxOpenOrders = uint64(20)
54 defaultExpiry = int64(72 * 3600)
55 maxExpiry = int64(7 * 24 * 3600)
56 maxFillsPerTx = 10
57 maxScanPerTx = 20
58 maxViewLimit = 50
59
60 // Upper bound for any single amount argument. Keeps every intermediate far
61 // from uint64 limits (balances are uint64 too) and rejects absurd inputs.
62 maxAmount = uint64(1000000000000000) // 1e15
63
64)
65
66// Reference value per rarity in milli-grin (RARITY_VALUE ×1000). Used ONLY
67// for the 50-grin minimum order size — never for prices.
68var prefMilli = [7]uint64{1000, 3200, 11000, 40000, 160000, 720000, 3600000}
69
70var (
71 pools avl.Tree // coin → *pool
72 lpPositions avl.Tree // coin+":"+addr → *lpPos
73 adminFees avl.Tree // coin → admin's accrued protocol fees (pull)
74 casinoPool avl.Tree // coin → casino pool balance (fees in, capped jackpots out)
75 makerBooks avl.Tree // maker → ";"-joined compact order records (≤ maxOpenOrders)
76 sides avl.Tree // want+">"+give → sorted fixed-width entries (≤ maxSideOrders)
77 openOrderTotal uint64
78 nextOrderId uint64
79 exPaused bool
80
81 // Lifetime accounting (Render + audits).
82 adminFeesWithdrawn avl.Tree // coin → lifetime withdrawn by the admin
83 adminGnotWithdrawn int64 // ugnot withdrawn by WithdrawFunds (≤ passRevenueUgnot)
84)
85
86// ── checked math ─────────────────────────────────────────────────────────────
87
88func addChk(a, b uint64) uint64 {
89 s, carry := bits.Add64(a, b, 0)
90 if carry != 0 {
91 panic("math overflow")
92 }
93 return s
94}
95
96func subChk(a, b uint64) uint64 {
97 if b > a {
98 panic("math underflow")
99 }
100 return a - b
101}
102
103// mulDiv = floor(a*b/c) with a 128-bit product; panics if c == 0 or the
104// quotient does not fit in 64 bits.
105func mulDiv(a, b, c uint64) uint64 {
106 if c == 0 {
107 panic("division by zero")
108 }
109 hi, lo := bits.Mul64(a, b)
110 if hi >= c {
111 panic("math overflow")
112 }
113 q, _ := bits.Div64(hi, lo, c)
114 return q
115}
116
117// mulDivUp = ceil(a*b/c).
118func mulDivUp(a, b, c uint64) uint64 {
119 if c == 0 {
120 panic("division by zero")
121 }
122 hi, lo := bits.Mul64(a, b)
123 if hi >= c {
124 panic("math overflow")
125 }
126 q, r := bits.Div64(hi, lo, c)
127 if r > 0 {
128 q = addChk(q, 1)
129 }
130 return q
131}
132
133// isqrt128 = floor(sqrt(a*b)) for the 128-bit product (Newton, bounded).
134func isqrt128(a, b uint64) uint64 {
135 hi, lo := bits.Mul64(a, b)
136 if hi == 0 {
137 return isqrt(lo)
138 }
139 // sqrt of a 128-bit number < 2^64. Start above the root and descend.
140 x := ^uint64(0)
141 for i := 0; i < 200; i++ {
142 if hi >= x { // product/x overflows ⇒ x below the root; cannot happen when descending from max
143 panic("isqrt: bad state")
144 }
145 q, _ := bits.Div64(hi, lo, x)
146 nx := (x >> 1) + (q >> 1) + (x & q & 1)
147 if nx >= x {
148 return x
149 }
150 x = nx
151 }
152 panic("isqrt: no convergence")
153}
154
155func isqrt(n uint64) uint64 {
156 if n < 2 {
157 return n
158 }
159 x := n
160 y := x/2 + 1
161 for i := 0; i < 200 && y < x; i++ {
162 x = y
163 y = (x + n/x) / 2
164 }
165 return x
166}
167
168// mulLess reports a*b < c*d using 128-bit products.
169func mulLess(a, b, c, d uint64) bool {
170 h1, l1 := bits.Mul64(a, b)
171 h2, l2 := bits.Mul64(c, d)
172 return h1 < h2 || (h1 == h2 && l1 < l2)
173}
174
175// requireBalance checks a balance before any write, so a failing call
176// changes nothing (validate-then-write).
177func requireBalance(addr, coin string, amt uint64) {
178 if have := balOf(addr, coin); have < amt {
179 panic("insufficient " + coin + ": need " + u(amt) + " have " + u(have))
180 }
181}
182
183func requireAmount(name string, v uint64) {
184 if v == 0 || v > maxAmount {
185 panic(name + " must be between 1 and " + strconv.FormatUint(maxAmount, 10))
186 }
187}
188
189func u(n uint64) string { return strconv.FormatUint(n, 10) }
190
191// ── gates ────────────────────────────────────────────────────────────────────
192
193// exchangeCaller returns the caller for exchange writes. Only end users (an
194// EOA MsgCall or the user's own MsgRun) may trade: other realms can't act
195// through the exchange, so there is no cross-realm call surface at all.
196func exchangeCaller(cur realm) string {
197 prev := cur.Previous()
198 if prev.PkgPath() != "" {
199 panic("exchange calls must come directly from a user")
200 }
201 return prev.Address().String()
202}
203
204// requireMarketOpen gates NEW activity (swaps, deposits, orders, fills).
205// Trading opens only after FinalizeMigration: MigrateBatch SETs balances, so
206// trading before that could duplicate or lose escrowed coins.
207func requireMarketOpen(caller string) {
208 if !migrationFinalized {
209 panic("exchange opens after FinalizeMigration")
210 }
211 if exPaused {
212 panic("exchange paused: new trades are off; unstake and cancel still work")
213 }
214 if isBlacklisted(caller) {
215 panic("address banned")
216 }
217}
218
219func requireDeadline(deadline int64) {
220 if deadline <= 0 {
221 panic("deadline required (unix seconds)")
222 }
223 if time.Now().Unix() > deadline {
224 panic("deadline passed")
225 }
226}
227
228func requireCoin(coin string) int {
229 r, ok := coinRarity[coin]
230 if !ok {
231 panic("unknown coin id: " + coin)
232 }
233 return r
234}
235
236// Trading is open to every account (Simion, 2026-10-11): no tool requirement
237// to swap, post, fill or stake. Anti-spam comes from the 50-grin order
238// minimum, the 2-grin post fee, the 0.5% taker/pool fees, the 20-open-orders
239// limit per maker and the 2,000-grin minimum pool depth.
240
241// No receive cap (Simion, 2026-10-11): every coin is freely transferable and
242// tradable, whatever tools the receiver owns.
243
244// ── protocol fees ────────────────────────────────────────────────────────────
245
246// accrueProtocolFee splits amt of coin 50/50: the floor half to the admin's
247// fee ledger, the rest to the casino pool. Both stay inside the realm.
248func accrueProtocolFee(coin string, amt uint64) {
249 if amt == 0 {
250 return
251 }
252 half := amt / 2
253 if half > 0 {
254 setU(&adminFees, coin, addChk(getU(&adminFees, coin), half))
255 }
256 setU(&casinoPool, coin, addChk(getU(&casinoPool, coin), amt-half))
257 casStat(coin, "", "fees", amt-half)
258 chain.Emit("ProtocolFeesAccrued", "coin", coin, "admin", u(half), "casino", u(amt-half))
259}
260
261// WithdrawProtocolFees moves the admin's OWN accrued fees for `coin` into the
262// admin's balance. It reads only adminFees (never reserves, escrow, the
263// casino pool or anyone's balance) and works while paused.
264func WithdrawProtocolFees(cur realm, coin string) uint64 {
265 requireAdmin(cur)
266 requireCoin(coin)
267 amt := getU(&adminFees, coin)
268 if amt == 0 {
269 panic("no fees to withdraw for " + coin)
270 }
271 adminFees.Remove(coin)
272 setU(&adminFeesWithdrawn, coin, addChk(getU(&adminFeesWithdrawn, coin), amt))
273 to := admin.String()
274 creditBal(to, coin, amt)
275 chain.Emit("ProtocolFeesWithdrawn", "coin", coin, "to", to, "amount", u(amt))
276 return amt
277}
278
279// ── casino pool (fee share + losing stakes in, wins out; never minted) ─────
280//
281// casinoPool[coin] total pool, including the reserved part
282// casinoReserved[coin] sum of potential net payouts of unsettled bets
283// casinoDay[coin] "day:snapshot:paid:received" for the current UTC day
284//
285// A bet is accepted only if the pool can pay it in full: its potential net
286// payout (= the stake, since a win pays 2×) is reserved at placement, so a
287// reveal never pays partially and the pool can't go below zero.
288
289
290
291
292
293
294
295
296
297// ── admin controls (launch only; none touch user funds) ──────────────────────
298
299// SetExchangePaused stops NEW swaps, deposits, orders and fills. It can't
300// stop RemoveLiquidity, CancelOrder, SweepExpiredOrders or fee withdrawals.
301func SetExchangePaused(cur realm, p bool) {
302 requireAdmin(cur)
303 exPaused = p
304 chain.Emit("ExchangePaused", "paused", strconv.FormatBool(p))
305}
306
307func ExchangeStatus() string {
308 return `{"open":` + strconv.FormatBool(migrationFinalized && !exPaused) +
309 `,"paused":` + strconv.FormatBool(exPaused) +
310 `,"finalized":` + strconv.FormatBool(migrationFinalized) +
311 `,"pools":` + strconv.Itoa(pools.Size()) +
312 `,"openOrders":` + strconv.FormatUint(openOrderTotal, 10) +
313 `,"nextOrderId":"` + u(nextOrderId) + `"` +
314 `,"poolFeeBps":` + u(poolFeeBps) + `,"protoFeeBps":` + u(protoFeeBps) +
315 `,"takerFeeBps":` + u(takerFeeBps) + `,"postFeeGrin":` + u(postFeeGrin) +
316 `,"minOpenDepthGrin":` + u(minOpenDepthGrin) + `,"warmupSecs":` + strconv.FormatInt(warmupSecs, 10) + `}`
317}
318
319func ProtocolFees(coin string) string {
320 return `{"coin":` + jsonStr(coin) + `,"admin":"` + u(getU(&adminFees, coin)) +
321 `","adminWithdrawn":"` + u(getU(&adminFeesWithdrawn, coin)) +
322 `","casino":"` + u(getU(&casinoPool, coin)) + `"}`
323}