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