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

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}