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.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}