Search Apps Documentation Source Content File Folder Download Copy Actions Download

otc.gno

8.22 Kb · 281 lines
  1package memba_token_otc_v2
  2
  3import (
  4	"chain"
  5	"chain/banker"
  6	"chain/runtime"
  7	"chain/runtime/unsafe"
  8	"gno.land/p/nt/avl/v0"
  9	"gno.land/p/nt/ufmt/v0"
 10	"gno.land/r/samcrew/memba_market_config"
 11	"gno.land/r/samcrew/tokenfactory_v2"
 12)
 13
 14// AdminAddress is the same 2-of-2 multisig used by the market config.
 15const AdminAddress = "g10kw7e55e9wc8j8v6904ck29dqwr9fm9u280juh"
 16
 17type Listing struct {
 18	Id        uint64
 19	Seller    address
 20	Symbol    string
 21	Amount    int64
 22	UnitPrice int64
 23	CreatedAt int64
 24}
 25
 26var (
 27	admin         address
 28	paused        bool
 29	listings      avl.Tree // uint64 -> *Listing
 30	nextListingId uint64 = 1
 31)
 32
 33func init() {
 34	admin = address(AdminAddress)
 35}
 36
 37func caller() address { return unsafe.PreviousRealm().Address() }
 38
 39// Pause acts as the kill switch for the engine (stops listing and filling).
 40func Pause(cur realm, state bool) {
 41	if caller() != admin {
 42		panic("unauthorized")
 43	}
 44	paused = state
 45}
 46
 47// ListTokens creates a new OTC listing for a tokenfactory_v2 token.
 48func ListTokens(cur realm, symbol string, amount, unitPrice int64) uint64 {
 49	if paused {
 50		panic("engine is paused")
 51	}
 52	if amount <= 0 {
 53		panic("amount must be > 0")
 54	}
 55	if unitPrice <= 0 {
 56		panic("unitPrice must be > 0")
 57	}
 58
 59	seller := caller()
 60
 61	// SECURITY: Ensure the token was minted by tokenfactory_v2.
 62	// Bank will panic if the symbol is not registered in the factory.
 63	_ = tokenfactory_v2.Bank(symbol)
 64
 65	// Verify the seller has enough balance and has approved this realm.
 66	bal := tokenfactory_v2.BalanceOf(symbol, seller)
 67	if bal < amount {
 68		panic("insufficient token balance")
 69	}
 70	allow := tokenfactory_v2.Allowance(symbol, seller, cur.Address())
 71	if allow < amount {
 72		panic("insufficient token allowance granted to OTC realm")
 73	}
 74
 75	id := nextListingId
 76	nextListingId++
 77
 78	listing := &Listing{
 79		Id:        id,
 80		Seller:    seller,
 81		Symbol:    symbol,
 82		Amount:    amount,
 83		UnitPrice: unitPrice,
 84		CreatedAt: runtime.ChainHeight(),
 85	}
 86
 87	listings.Set(ufmt.Sprintf("%d", id), listing)
 88
 89	chain.Emit("Listed",
 90		"id", ufmt.Sprintf("%d", id),
 91		"seller", string(seller),
 92		"symbol", symbol,
 93		"amount", ufmt.Sprintf("%d", amount),
 94		"unitPrice", ufmt.Sprintf("%d", unitPrice),
 95	)
 96
 97	return id
 98}
 99
100// CancelListing removes an OTC listing. Only the seller can cancel.
101func CancelListing(cur realm, listingId uint64) {
102	seller := caller()
103	listingRaw, exists := listings.Get(ufmt.Sprintf("%d", listingId))
104	if !exists {
105		panic("listing not found")
106	}
107	listing := listingRaw.(*Listing)
108
109	if listing.Seller != seller {
110		panic("unauthorized: not the seller")
111	}
112
113	listings.Remove(ufmt.Sprintf("%d", listingId))
114
115	chain.Emit("Cancelled",
116		"id", ufmt.Sprintf("%d", listingId),
117	)
118}
119
120// Fill executes a partial or full fill of an OTC listing.
121func Fill(cur realm, listingId uint64, qty, expectedUnitPrice int64) {
122	if paused {
123		panic("engine is paused")
124	}
125
126	// P0 fund-guard: a payable entrypoint MUST be a direct user call. unsafe.OriginSend()
127	// reports the tx-level `--send`, credited to the DIRECT message target realm — not to
128	// an inner realm reached via a cross-call. This realm forwards funds immediately and
129	// holds no standing pool, so a routed fill self-reverts today (MED) — but the guard is
130	// applied uniformly with the other payable entrypoints to close the residual-balance
131	// edge (stranded/donated ugnot) and prevent regressions.
132	if !unsafe.PreviousRealm().IsUserCall() {
133		panic("Fill must be a direct user call")
134	}
135
136	if qty <= 0 {
137		panic("qty must be > 0")
138	}
139
140	buyer := caller()
141
142	listingRaw, exists := listings.Get(ufmt.Sprintf("%d", listingId))
143	if !exists {
144		panic("listing not found")
145	}
146	listing := listingRaw.(*Listing)
147
148	// Slippage & front-run protection
149	if listing.UnitPrice != expectedUnitPrice {
150		panic("price mismatch (front-run protection)")
151	}
152
153	if listing.Amount < qty {
154		panic("requested qty exceeds available listing amount")
155	}
156
157	// Verify seller still has balance and allowance
158	bal := tokenfactory_v2.BalanceOf(listing.Symbol, listing.Seller)
159	if bal < qty {
160		panic("seller has insufficient balance")
161	}
162	allow := tokenfactory_v2.Allowance(listing.Symbol, listing.Seller, cur.Address())
163	if allow < qty {
164		panic("seller has insufficient allowance")
165	}
166
167	// Calculate cost — guard the int64 multiplication against overflow. qty>0 (checked
168	// above) and unitPrice>0 (checked at ListTokens), so a valid product is >0 and
169	// cost/qty == unitPrice; a wrapped product fails one of those.
170	cost := qty * listing.UnitPrice
171	if cost <= 0 || cost/qty != listing.UnitPrice {
172		panic("cost overflow")
173	}
174
175	// Dust-fill evasion protection (e.g., minimum notional > 0 if fee > 0)
176	feeBps, treasury := memba_market_config.GetLaneConfig("token")
177	if feeBps > int(memba_market_config.MaxFeeBPS) {
178		feeBps = int(memba_market_config.DefaultFeeBPS) // safe fallback clamp
179	}
180
181	fee := (cost * int64(feeBps)) / 10000
182	if feeBps > 0 && fee == 0 {
183		panic("dust fill: notional too small to cover minimum fee")
184	}
185
186	// Require EXACT payment sent WITH this call. Read OriginSend (the coins attached to
187	// the call) — NOT the caller's wallet balance — mirroring escrow_v2.FundMilestone.
188	// Exact-coin closes both the overpay-trap (excess had no refund/sweep) and the
189	// wallet-balance bypass (a funded caller could trigger a payout without paying).
190	sent := unsafe.OriginSend()
191	if sent.AmountOf("ugnot") != cost {
192		panic(ufmt.Sprintf("must send exactly %d ugnot (sent %d)", cost, sent.AmountOf("ugnot")))
193	}
194
195	// CEI: Update state first
196	listing.Amount -= qty
197	if listing.Amount == 0 {
198		listings.Remove(ufmt.Sprintf("%d", listingId))
199	} else {
200		listings.Set(ufmt.Sprintf("%d", listingId), listing)
201	}
202
203	// Route funds from the realm's own account (holding the exact ugnot just received).
204	sellerProceeds := cost - fee
205	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
206	realmAddr := unsafe.CurrentRealm().Address()
207	if fee > 0 {
208		bnk.SendCoins(realmAddr, treasury, chain.Coins{chain.NewCoin("ugnot", fee)})
209	}
210	if sellerProceeds > 0 {
211		bnk.SendCoins(realmAddr, listing.Seller, chain.Coins{chain.NewCoin("ugnot", sellerProceeds)})
212	}
213
214	// Transfer tokens and measure delta (fee-on-transfer protection)
215	buyerBalBefore := tokenfactory_v2.BalanceOf(listing.Symbol, buyer)
216	
217	tokenfactory_v2.TransferFrom(cross(cur), listing.Symbol, listing.Seller, buyer, qty)
218	
219	buyerBalAfter := tokenfactory_v2.BalanceOf(listing.Symbol, buyer)
220	delta := buyerBalAfter - buyerBalBefore
221	if delta != qty {
222		panic("token transfer delta mismatch (fee-on-transfer not supported)")
223	}
224
225	chain.Emit("Filled",
226		"id", ufmt.Sprintf("%d", listingId),
227		"buyer", string(buyer),
228		"seller", string(listing.Seller),
229		"symbol", listing.Symbol,
230		"qty", ufmt.Sprintf("%d", qty),
231		"unitPrice", ufmt.Sprintf("%d", listing.UnitPrice),
232		"feeBps", ufmt.Sprintf("%d", feeBps),
233		"fee", ufmt.Sprintf("%d", fee),
234	)
235}
236
237// ── Read Getters ─────────────────────────────────────────────────────────────
238
239// EngineAddress returns this realm's on-chain address — the account sellers approve to
240// move their tokens and the settlement account. Exposed so the frontend/deploy ceremony
241// can wire the engine without hardcoding the derived address (mirrors nft_market_v3
242// MarketAddress()).
243func EngineAddress() address {
244	return unsafe.CurrentRealm().Address()
245}
246
247func GetListing(listingId uint64) *Listing {
248	listingRaw, exists := listings.Get(ufmt.Sprintf("%d", listingId))
249	if !exists {
250		return nil
251	}
252	return listingRaw.(*Listing)
253}
254
255// Note: In production we'd want paginated getters here to prevent O(N) gas limits.
256// For v1.1, we'll return all active listings or window them.
257func GetListings() []*Listing {
258	var result []*Listing
259	listings.Iterate("", "", func(key string, value interface{}) bool {
260		result = append(result, value.(*Listing))
261		return false
262	})
263	return result
264}
265
266// GetListingsCSV returns listings formatted as "ID|Seller|Symbol|UnitPrice|Amount,ID|..."
267// to avoid the frontend needing to parse raw Amino struct outputs.
268func GetListingsCSV() string {
269	out := ""
270	listings.Iterate("", "", func(key string, value interface{}) bool {
271		l := value.(*Listing)
272		row := ufmt.Sprintf("%d|%s|%s|%d|%d", l.Id, string(l.Seller), l.Symbol, l.UnitPrice, l.Amount)
273		if out == "" {
274			out = row
275		} else {
276			out += "," + row
277		}
278		return false
279	})
280	return out
281}