Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}