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}