@gangfun/cli 0.1.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/chain.js CHANGED
@@ -1,89 +1,280 @@
1
- import { createPublicClient, http, formatEther, parseEther, encodeFunctionData, decodeEventLog } from 'viem'
1
+ import { createPublicClient, http, formatEther, parseEther, encodeFunctionData, decodeEventLog, zeroAddress } from 'viem'
2
2
  import { bsc } from 'viem/chains'
3
- import { LAUNCHPAD_ABI, ERC20_ABI } from './abi.js'
3
+ import { SOFT_ABI, V2_ABI, ERC20_ABI } from './abi.js'
4
+
5
+ export const ADDR = {
6
+ v2: '0x1B8F3f69DFa0a7F63d308C7EB0Fd38D8b099966E',
7
+ soft: '0x4E6f13c8A453c1b8b4E20b590082bb233a5b80d7',
8
+ u: '0xF81C4E51c48466c6025B0165b1Ea0F6978c712cb',
9
+ s: '0x60aee38576035E757deD30fF8db8A8DA476430C1',
10
+ wbnb: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c',
11
+ }
12
+
13
+ const FORBIDDEN_CREATE = new Set([ADDR.soft, ADDR.u, ADDR.s].map((a) => a.toLowerCase()))
4
14
 
5
15
  export const CONFIG = {
6
16
  rpc: process.env.GANG_RPC_URL || 'https://bsc-dataseed.binance.org',
7
- launchpad: process.env.GANG_LAUNCHPAD || '0x315Ba2e8952D06094CC636b2B84229FA32fb23A7',
17
+ launchpad: process.env.GANG_LAUNCHPAD || ADDR.v2,
8
18
  chainId: '56',
9
19
  }
10
20
 
11
21
  export const client = createPublicClient({ chain: bsc, transport: http(CONFIG.rpc) })
12
- const pad = (functionName, args = []) => client.readContract({ address: CONFIG.launchpad, abi: LAUNCHPAD_ABI, functionName, args })
13
- const erc = (address, functionName, args = []) => client.readContract({ address, abi: ERC20_ABI, functionName, args })
14
-
15
22
  export const fmt = (wei) => formatEther(wei)
16
23
  export const fmtTok = (units) => Number(formatEther(units)).toLocaleString('en-US', { maximumFractionDigits: 2 })
17
- export { parseEther, encodeFunctionData }
24
+ export { parseEther, encodeFunctionData, zeroAddress }
18
25
 
19
- export async function tokenInfo(token, me) {
20
- const [c, name, symbol, locked, grad, cap] = await Promise.all([
21
- pad('curves', [token]), erc(token, 'name'), erc(token, 'symbol'), erc(token, 'locked'),
22
- pad('GRADUATION_BNB'), pad('MAX_PER_WALLET'),
23
- ])
24
- const [creator, realBnb, sold, graduated, pair] = c
25
- if (creator === '0x0000000000000000000000000000000000000000') {
26
+ const erc = (address, functionName, args = []) => client.readContract({ address, abi: ERC20_ABI, functionName, args })
27
+ const read = (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args })
28
+
29
+ export function assertCreateTarget(addr = CONFIG.launchpad) {
30
+ const a = String(addr || '').toLowerCase()
31
+ if (a !== ADDR.v2.toLowerCase()) {
26
32
  throw new Error(
27
- `not a Gang token: ${token} (launchpad ${CONFIG.launchpad}). ` +
28
- `Upgrade: npm i -g @gangfun/cli@0.1.3 --registry=https://npm.pkg.github.com OR ` +
29
- `export GANG_LAUNCHPAD=0x315Ba2e8952D06094CC636b2B84229FA32fb23A7`
33
+ `create refused: only LaunchpadV2 ${ADDR.v2} (got ${addr}). ` +
34
+ `Soft/LaunchpadU/LaunchpadS are not create routes — buy/sell/claim existing only. ` +
35
+ `Unset GANG_LAUNCHPAD or set it to V2.`,
30
36
  )
31
37
  }
32
- // curve end price (1e-7 BNB) for graduated tokens; live spot otherwise
33
- const price = graduated ? 100000000000n : await pad('spotPrice', [token])
38
+ }
39
+
40
+ const PADS = [
41
+ { id: 'v2', address: ADDR.v2, kind: 'v2', abi: V2_ABI },
42
+ { id: 'soft', address: ADDR.soft, kind: 'soft', abi: SOFT_ABI },
43
+ { id: 'u', address: ADDR.u, kind: 'v2', abi: V2_ABI },
44
+ { id: 's', address: ADDR.s, kind: 'v2', abi: V2_ABI },
45
+ ]
46
+
47
+ /** Resolve which pad hosts `token` by probing curves(token).creator != 0. */
48
+ export async function resolvePad(token) {
49
+ for (const pad of PADS) {
50
+ try {
51
+ const c = await read(pad.address, pad.abi, 'curves', [token])
52
+ const creator = Array.isArray(c) ? c[0] : c.creator
53
+ if (creator && creator !== zeroAddress) {
54
+ return { ...pad, curve: c }
55
+ }
56
+ } catch { /* wrong ABI shape or empty */ }
57
+ }
58
+ throw new Error(
59
+ `not a Gang token on known pads (V2/Soft/U/S): ${token}. ` +
60
+ `Upgrade: npm i -g @gangfun/cli@latest OR npm i -g github:gangfun/cli`,
61
+ )
62
+ }
63
+
64
+ function softInfoFromCurve(c) {
65
+ const [creator, realBnb, sold, graduated, pair] = c
66
+ return { kind: 'soft', creator, raised: realBnb, sold, graduated, pair, quote: ADDR.wbnb, gradAmt: null, taxed: false, virtualQuote: null, refPair: null }
67
+ }
68
+
69
+ function v2InfoFromCurve(c) {
70
+ const [creator, quote, realQuote, sold, gradAmt, virtualQuote, graduated, taxed, pair, refPair] = c
71
+ return { kind: 'v2', creator, raised: realQuote, sold, graduated, pair, quote, gradAmt, taxed, virtualQuote, refPair }
72
+ }
73
+
74
+ export async function tokenInfo(token, me) {
75
+ const pad = await resolvePad(token)
76
+ const base = pad.kind === 'soft' ? softInfoFromCurve(pad.curve) : v2InfoFromCurve(pad.curve)
77
+ const [name, symbol] = await Promise.all([erc(token, 'name'), erc(token, 'symbol')])
78
+ let locked = null
79
+ try { locked = await erc(token, 'locked') } catch { /* */ }
80
+ let cap = null
81
+ try { cap = await read(pad.address, pad.abi, 'MAX_PER_WALLET') } catch { /* */ }
82
+
83
+ let price = null
84
+ if (pad.kind === 'soft') {
85
+ try { price = base.graduated ? 100000000000n : await read(pad.address, pad.abi, 'spotPrice', [token]) } catch { /* */ }
86
+ } else if (base.virtualQuote && base.virtualQuote > 0n) {
87
+ // spot ≈ virtualQuote / (VIRTUAL_TOKEN - sold) * 1e18-ish; expose raised/grad instead
88
+ const vq = BigInt(base.virtualQuote)
89
+ const sold = BigInt(base.sold)
90
+ const VIRTUAL_TOKEN = 1066666666666666666666666667n
91
+ const tReserve = VIRTUAL_TOKEN - sold
92
+ if (tReserve > 0n) price = (vq * 10n ** 18n) / tReserve
93
+ }
94
+
95
+ const grad = base.gradAmt != null ? BigInt(base.gradAmt) : (pad.kind === 'soft' ? await read(pad.address, pad.abi, 'GRADUATION_BNB') : 15n * 10n ** 18n)
96
+ const raised = BigInt(base.raised)
34
97
  const info = {
35
- token, name, symbol, creator, graduated, pair: graduated ? pair : null, locked,
36
- priceBnb: Number(price) / 1e18,
37
- fdvBnb: (Number(price) / 1e18) * 1e9,
38
- raisedBnb: fmt(realBnb), graduationBnb: fmt(grad),
39
- progressPct: Number((realBnb * 10000n) / grad) / 100,
40
- soldTokens: fmtTok(sold), walletCapTokens: fmtTok(cap),
98
+ token, name, symbol, creator: base.creator, graduated: base.graduated,
99
+ pair: base.graduated ? base.pair : null, locked,
100
+ pad: pad.id, padAddress: pad.address, kind: base.kind,
101
+ quote: base.quote, taxed: !!base.taxed,
102
+ priceBnb: price != null ? Number(price) / 1e18 : null,
103
+ fdvBnb: price != null ? (Number(price) / 1e18) * 1e9 : null,
104
+ raisedBnb: fmt(raised), graduationBnb: fmt(grad),
105
+ progressPct: grad > 0n ? Number((raised * 10000n) / grad) / 100 : 0,
106
+ soldTokens: fmtTok(base.sold), walletCapTokens: cap != null ? fmtTok(cap) : null,
107
+ logoURI: null,
108
+ }
109
+ if (pad.kind === 'v2') {
110
+ try { info.logoURI = await read(pad.address, pad.abi, 'logoURIOf', [token]) } catch { /* U/S may lack */ }
41
111
  }
42
112
  if (me) {
43
- const [bal, room] = await Promise.all([erc(token, 'balanceOf', [me]), pad('walletRoom', [token, me])])
44
- info.myBalance = fmtTok(bal); info.myBalanceRaw = bal.toString(); info.myRoomTokens = fmtTok(room); info.myRoomRaw = room.toString()
113
+ const bal = await erc(token, 'balanceOf', [me])
114
+ info.myBalance = fmtTok(bal); info.myBalanceRaw = bal.toString()
115
+ try {
116
+ const room = await read(pad.address, pad.abi, 'walletRoom', [token, me])
117
+ info.myRoomTokens = fmtTok(room); info.myRoomRaw = room.toString()
118
+ } catch { /* */ }
45
119
  }
46
120
  return info
47
121
  }
48
122
 
49
123
  export async function listTokens(me) {
50
- const n = await pad('tokenCount')
51
- const addrs = await Promise.all(Array.from({ length: Number(n) }, (_, i) => pad('allTokens', [BigInt(i)])))
52
- return Promise.all(addrs.map((a) => tokenInfo(a, me)))
124
+ const out = []
125
+ for (const pad of PADS) {
126
+ try {
127
+ const n = await read(pad.address, pad.abi, 'tokenCount')
128
+ const addrs = await Promise.all(Array.from({ length: Number(n) }, (_, i) => read(pad.address, pad.abi, 'allTokens', [BigInt(i)])))
129
+ for (const a of addrs) {
130
+ try { out.push(await tokenInfo(a, me)) } catch { /* skip */ }
131
+ }
132
+ } catch { /* pad down */ }
133
+ }
134
+ return out
53
135
  }
54
136
 
55
- export async function quoteBuy(token, buyer, bnb) {
56
- const gross = parseEther(bnb)
57
- const [tokensOut, bnbIn, fee, refund] = await pad('quoteBuy', [token, buyer, gross])
58
- return { bnbGross: bnb, tokensOut, bnbIn, fee, refund, tokensOutFmt: fmtTok(tokensOut), bnbInFmt: fmt(bnbIn), feeFmt: fmt(fee), refundFmt: fmt(refund) }
137
+ /** LaunchpadV2 curve constants (LaunchpadV2.sol) — unique off-chain path = on-chain math. */
138
+ const V2_VIRTUAL_TOKEN = 1066666666666666666666666667n
139
+ const V2_BPS = 10000n
140
+ const V2_PROTOCOL_FEE_BPS = 100n // 1%
141
+
142
+ const ceilDiv = (a, b) => (b === 0n ? 0n : (a + b - 1n) / b)
143
+
144
+ /** Contract-equivalent buy quote (_prepBuy + _tokensOut / _quoteInFor). No Soft/U/S fallback. */
145
+ function v2QuoteBuyFromCurve(base, quoteIn, room) {
146
+ const vq = BigInt(base.virtualQuote)
147
+ const sold = BigInt(base.sold)
148
+ const raised = BigInt(base.raised)
149
+ const gradAmt = BigInt(base.gradAmt)
150
+ const tokensOutOf = (net) => {
151
+ const k = vq * V2_VIRTUAL_TOKEN
152
+ const qReserve = vq + raised
153
+ const tReserve = V2_VIRTUAL_TOKEN - sold
154
+ const newT = ceilDiv(k, qReserve + net)
155
+ return tReserve - newT
156
+ }
157
+ const quoteInFor = (tokensOut) => {
158
+ const k = vq * V2_VIRTUAL_TOKEN
159
+ const qReserve = vq + raised
160
+ const tReserve = V2_VIRTUAL_TOKEN - sold
161
+ return ceilDiv(k, tReserve - tokensOut) - qReserve
162
+ }
163
+ const recomputeFee = (gross, netIn) => {
164
+ let spentGross = ceilDiv(netIn * V2_BPS, V2_BPS - V2_PROTOCOL_FEE_BPS)
165
+ if (spentGross > gross) spentGross = gross
166
+ const taxPlat = (spentGross * V2_PROTOCOL_FEE_BPS) / V2_BPS
167
+ const net = spentGross - taxPlat
168
+ const refundGross = gross - spentGross
169
+ return { taxPlat, net, refundGross, spentGross }
170
+ }
171
+
172
+ let taxPlat = (quoteIn * V2_PROTOCOL_FEE_BPS) / V2_BPS
173
+ let net = quoteIn - taxPlat
174
+ let refundGross = 0n
175
+ const remaining = gradAmt > raised ? gradAmt - raised : 0n
176
+ if (net > remaining) {
177
+ net = remaining
178
+ ;({ taxPlat, net, refundGross } = recomputeFee(quoteIn, net))
179
+ if (net > remaining) net = remaining
180
+ }
181
+ let tokensOut = tokensOutOf(net)
182
+ if (room != null && tokensOut > room) {
183
+ tokensOut = room
184
+ net = quoteInFor(room)
185
+ ;({ taxPlat, net, refundGross } = recomputeFee(quoteIn, net))
186
+ }
187
+ return { tokensOut, spent: quoteIn - refundGross, fee: taxPlat, refund: refundGross }
188
+ }
189
+
190
+ /** Contract-equivalent sell quote (_quoteOut + 1% platform fee). */
191
+ function v2QuoteSellFromCurve(base, tokensIn) {
192
+ const vq = BigInt(base.virtualQuote)
193
+ const sold = BigInt(base.sold)
194
+ const raised = BigInt(base.raised)
195
+ const k = vq * V2_VIRTUAL_TOKEN
196
+ const qReserve = vq + raised
197
+ const tReserve = V2_VIRTUAL_TOKEN - sold
198
+ const newQ = ceilDiv(k, tReserve + tokensIn)
199
+ const quoteOut = qReserve - newQ
200
+ const fee = (quoteOut * V2_PROTOCOL_FEE_BPS) / V2_BPS
201
+ return { out: quoteOut - fee, fee }
202
+ }
203
+
204
+ export async function quoteBuy(token, buyer, amountHuman) {
205
+ const pad = await resolvePad(token)
206
+ const gross = parseEther(amountHuman)
207
+ if (pad.kind === 'soft') {
208
+ const [tokensOut, bnbIn, fee, refund] = await read(pad.address, pad.abi, 'quoteBuy', [token, buyer, gross])
209
+ return { kind: 'soft', pad: pad.id, padAddress: pad.address, amountHuman, tokensOut, spent: bnbIn, fee, refund,
210
+ tokensOutFmt: fmtTok(tokensOut), spentFmt: fmt(bnbIn), feeFmt: fmt(fee), refundFmt: fmt(refund), unit: 'BNB',
211
+ quotePath: 'onchain-quoteBuy' }
212
+ }
213
+ // Unique path: LaunchpadV2.sol _prepBuy / _tokensOut / _quoteInFor (no on-chain quoteBuy view).
214
+ const base = v2InfoFromCurve(pad.curve)
215
+ let room = null
216
+ try { room = await read(pad.address, pad.abi, 'walletRoom', [token, buyer]) } catch { /* */ }
217
+ const q = v2QuoteBuyFromCurve(base, gross, room)
218
+ return { kind: 'v2', pad: pad.id, padAddress: pad.address, quote: base.quote, amountHuman,
219
+ tokensOut: q.tokensOut, spent: q.spent, fee: q.fee, refund: q.refund,
220
+ tokensOutFmt: fmtTok(q.tokensOut), spentFmt: fmt(q.spent), feeFmt: fmt(q.fee), refundFmt: fmt(q.refund),
221
+ unit: 'quote', quotePath: 'LaunchpadV2.sol#_prepBuy', deterministic: true }
59
222
  }
60
223
 
61
224
  export async function quoteSell(token, tokensIn) {
62
- const [bnbToUser, fee] = await pad('quoteSell', [token, tokensIn])
63
- return { tokensIn, bnbToUser, fee, tokensInFmt: fmtTok(tokensIn), bnbToUserFmt: fmt(bnbToUser), feeFmt: fmt(fee) }
225
+ const pad = await resolvePad(token)
226
+ if (pad.kind === 'soft') {
227
+ const [bnbToUser, fee] = await read(pad.address, pad.abi, 'quoteSell', [token, tokensIn])
228
+ return { kind: 'soft', pad: pad.id, padAddress: pad.address, tokensIn, out: bnbToUser, fee,
229
+ tokensInFmt: fmtTok(tokensIn), outFmt: fmt(bnbToUser), feeFmt: fmt(fee), unit: 'BNB',
230
+ quotePath: 'onchain-quoteSell' }
231
+ }
232
+ const base = v2InfoFromCurve(pad.curve)
233
+ const q = v2QuoteSellFromCurve(base, tokensIn)
234
+ return { kind: 'v2', pad: pad.id, padAddress: pad.address, quote: base.quote, tokensIn, out: q.out, fee: q.fee,
235
+ tokensInFmt: fmtTok(tokensIn), outFmt: fmt(q.out), feeFmt: fmt(q.fee), unit: 'quote',
236
+ quotePath: 'LaunchpadV2.sol#_quoteOut', deterministic: true }
237
+ }
238
+
239
+ export async function isAgent(account) {
240
+ // Prefer V2 gate (create path); fall back Soft.
241
+ try { return await read(ADDR.v2, V2_ABI, 'isAgent', [account]) } catch { /* */ }
242
+ try { return await read(ADDR.soft, SOFT_ABI, 'isAgent', [account]) } catch { /* */ }
243
+ return false
64
244
  }
65
245
 
66
- export const isAgent = (a) => pad('isAgent', [a])
67
246
  export const balanceOf = (token, a) => erc(token, 'balanceOf', [a])
68
- export const allowance = (token, a) => erc(token, 'allowance', [a, CONFIG.launchpad])
247
+ export async function allowance(token, owner, spender) {
248
+ return erc(token, 'allowance', [owner, spender])
249
+ }
69
250
 
70
251
  export const calldata = {
71
- buy: (token, minTokensOut) => encodeFunctionData({ abi: LAUNCHPAD_ABI, functionName: 'buy', args: [token, minTokensOut] }),
72
- sell: (token, tokensIn, minBnbOut) => encodeFunctionData({ abi: LAUNCHPAD_ABI, functionName: 'sell', args: [token, tokensIn, minBnbOut] }),
73
- approve: (amount) => encodeFunctionData({ abi: ERC20_ABI, functionName: 'approve', args: [CONFIG.launchpad, amount] }),
74
- create: (name, symbol) => encodeFunctionData({ abi: LAUNCHPAD_ABI, functionName: 'create', args: [name, symbol] }),
252
+ softBuy: (token, minTokensOut) => encodeFunctionData({ abi: SOFT_ABI, functionName: 'buy', args: [token, minTokensOut] }),
253
+ softSell: (token, tokensIn, minBnbOut) => encodeFunctionData({ abi: SOFT_ABI, functionName: 'sell', args: [token, tokensIn, minBnbOut] }),
254
+ v2Buy: (token, quoteIn, minTokensOut) => encodeFunctionData({ abi: V2_ABI, functionName: 'buy', args: [token, quoteIn, minTokensOut] }),
255
+ v2Sell: (token, tokensIn, minQuoteOut) => encodeFunctionData({ abi: V2_ABI, functionName: 'sell', args: [token, tokensIn, minQuoteOut] }),
256
+ approve: (spender, amount) => encodeFunctionData({ abi: ERC20_ABI, functionName: 'approve', args: [spender, amount] }),
257
+ createV2: (name, symbol, logoURI, taxed, pairOrQuote) =>
258
+ encodeFunctionData({ abi: V2_ABI, functionName: 'create', args: [name, symbol, logoURI, taxed, pairOrQuote] }),
75
259
  }
76
260
 
77
261
  export async function decodeTx(hash) {
78
262
  const r = await client.waitForTransactionReceipt({ hash, timeout: 120_000 })
79
263
  const events = []
264
+ const known = new Map(PADS.map((p) => [p.address.toLowerCase(), p]))
80
265
  for (const log of r.logs) {
81
- if (log.address.toLowerCase() !== CONFIG.launchpad.toLowerCase()) continue
266
+ const pad = known.get(log.address.toLowerCase())
267
+ if (!pad) continue
82
268
  try {
83
- const ev = decodeEventLog({ abi: LAUNCHPAD_ABI, data: log.data, topics: log.topics })
84
- const a = Object.fromEntries(Object.entries(ev.args).map(([k, v]) => [k, typeof v === 'bigint' ? (k.toLowerCase().includes('token') && k !== 'token' ? fmtTok(v) : fmt(v)) : v]))
85
- events.push({ event: ev.eventName, ...a })
86
- } catch { /* not ours */ }
269
+ const ev = decodeEventLog({ abi: pad.abi, data: log.data, topics: log.topics })
270
+ const a = Object.fromEntries(Object.entries(ev.args).map(([k, v]) => {
271
+ if (typeof v !== 'bigint') return [k, v]
272
+ const lk = k.toLowerCase()
273
+ if (lk.includes('token') && k !== 'token' && !lk.includes('amount')) return [k, fmtTok(v)]
274
+ return [k, fmt(v)]
275
+ }))
276
+ events.push({ event: ev.eventName, pad: pad.id, ...a })
277
+ } catch { /* */ }
87
278
  }
88
279
  return { hash, status: r.status, block: Number(r.blockNumber), events }
89
280
  }
@@ -0,0 +1,266 @@
1
+ /** Discovery for agents: ranked lists + a polling event stream, read from the gang.fun indexer API. */
2
+ export const API = (process.env.GANG_API || 'https://gang.fun/api').replace(/\/+$/, '')
3
+
4
+ const SOFT_GRAD_BNB = 20
5
+ const MILESTONES = [25, 50, 75, 90]
6
+ const MARKET_PAGES = 10 // × 200 rows per track
7
+ const TRADE_PAGE = 50
8
+ const TRADE_PAGES = 4
9
+ const SEEN_CAP = 2000
10
+ const num = (v) => (v == null || v === '' ? 0 : Number(v))
11
+ const usd = (v) => (Number.isFinite(v) ? Math.round(v * 100) / 100 : null)
12
+ const now = () => Math.floor(Date.now() / 1000)
13
+
14
+ /** `stop` (optional AbortSignal) cancels an in-flight request, e.g. when `gang watch --timeout` expires. */
15
+ async function get(path, stop) {
16
+ // own controller instead of AbortSignal.any (Node ≥20.3); listener removed after each request so a
17
+ // long-lived `stop` signal doesn't pile up one listener per poll
18
+ const ctl = new AbortController()
19
+ const timer = setTimeout(() => ctl.abort(new Error('timed out after 15s')), 15_000)
20
+ const onStop = () => ctl.abort(new Error('stopped'))
21
+ if (stop?.aborted) onStop()
22
+ else stop?.addEventListener('abort', onStop, { once: true })
23
+ try {
24
+ let r
25
+ try {
26
+ r = await fetch(`${API}${path}`, { signal: ctl.signal, headers: { accept: 'application/json' } })
27
+ } catch (e) {
28
+ if (stop?.aborted) throw e
29
+ throw new Error(`gang API unreachable (${API}${path}): ${ctl.signal.reason?.message || e.message}. On-chain fallback without volume: \`gang list\``)
30
+ }
31
+ if (r.status === 404) return null
32
+ if (!r.ok) throw new Error(`gang API ${r.status} on ${path}`)
33
+ try {
34
+ return await r.json()
35
+ } catch (e) {
36
+ // body read can also hit the 15s timeout or a truncated/non-JSON body; keep the path in the message
37
+ if (stop?.aborted) throw e
38
+ throw new Error(`gang API bad response on ${path}: ${ctl.signal.reason?.message || e.message}`)
39
+ }
40
+ } finally {
41
+ clearTimeout(timer)
42
+ stop?.removeEventListener('abort', onStop)
43
+ }
44
+ }
45
+
46
+ /** "90" | "45s" | "30m" | "2h" | "1d" → seconds */
47
+ export function parseDuration(raw, name) {
48
+ const m = String(raw ?? '').trim().match(/^(\d+(?:\.\d+)?)([smhd]?)$/i)
49
+ if (!m) throw new Error(`--${name} must look like 90, 45s, 30m, 2h or 1d (got ${raw})`)
50
+ return Math.round(Number(m[1]) * { '': 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase()])
51
+ }
52
+
53
+ /**
54
+ * One shape for both API tracks. `v2` = quote-ERC20 curves (/stocks/tokens, LaunchpadV2 family);
55
+ * `soft` = legacy BNB curve (/tokens, 20 BNB graduation). Amounts are human units of `quote`.
56
+ */
57
+ export function normalize(r) {
58
+ const v2 = r.track === 'stocks'
59
+ const quote = v2 ? (r.quoteSymbol || '?') : 'BNB'
60
+ const raised = v2 ? num(r.raisedQuote) : num(r.raisedBnb)
61
+ const target = v2 ? num(r.gradAmtQuote) : SOFT_GRAD_BNB
62
+ const remaining = r.graduated ? 0 : (v2 ? num(r.graduationRemainingQuote) : Math.max(0, target - raised))
63
+ const quoteUsd = v2 ? num(r.quoteUsd) : num(r.bnbUsd)
64
+ return {
65
+ address: r.address, symbol: r.symbol, name: r.name, track: v2 ? 'v2' : 'soft',
66
+ quote, quoteAddress: v2 ? r.quoteAddress : null, taxed: v2 ? !!r.taxed : false,
67
+ graduated: !!r.graduated, pair: r.pair || null,
68
+ progressPct: num(r.progressPct), raised, graduationTarget: target, remaining,
69
+ remainingUsd: quoteUsd ? usd(remaining * quoteUsd) : null,
70
+ priceUsd: r.priceUsd ?? null, fdvUsd: usd(num(r.fdvUsd)),
71
+ volume24hUsd: usd(num(r.volume24hUsd)), trades24h: num(r.trades24h), trades: num(r.trades), holders: num(r.holders),
72
+ createdAt: r.createdAt, ageMin: r.createdAt ? Math.floor((now() - r.createdAt) / 60) : null,
73
+ lastTradeAt: r.lastTradeAt || null, creatorHandle: r.creatorHandle || null, logoUrl: r.logoUrl || null,
74
+ }
75
+ }
76
+
77
+ async function pages(path, stop) {
78
+ const rows = []
79
+ for (let offset = 0, i = 0; i < MARKET_PAGES; i++) {
80
+ const sep = path.includes('?') ? '&' : '?'
81
+ const page = await get(`${path}${sep}limit=200&offset=${offset}`, stop)
82
+ // list routes always exist: a 404 is an outage, never "no tokens"
83
+ if (!page) throw new Error(`gang API 404 on ${path}`)
84
+ rows.push(...(page.items || []))
85
+ if (!page.hasMore || page.nextOffset == null) return { rows, truncated: false }
86
+ offset = page.nextOffset
87
+ }
88
+ return { rows, truncated: true }
89
+ }
90
+
91
+ /** All indexed tokens, normalized and de-duplicated. track: all | v2 | soft */
92
+ export async function fetchMarket(track = 'all', stop) {
93
+ if (!['all', 'v2', 'soft'].includes(track)) throw new Error(`--track must be all, v2 or soft (got ${track})`)
94
+ const none = { rows: [], truncated: false }
95
+ const [v2, soft] = await Promise.all([
96
+ track === 'soft' ? none : pages('/stocks/tokens', stop),
97
+ track === 'v2' ? none : pages('/tokens?sort=new', stop),
98
+ ])
99
+ const seen = new Map()
100
+ for (const r of [...v2.rows, ...soft.rows]) {
101
+ const t = normalize(r)
102
+ if (!seen.has(t.address)) seen.set(t.address, t)
103
+ }
104
+ return { tokens: [...seen.values()], truncated: v2.truncated || soft.truncated }
105
+ }
106
+
107
+ const hint = (t) => `gang quote --token ${t.address} --bnb <amount in ${t.quote}>`
108
+ const withHint = (t) => ({ ...t, next: t.graduated ? `trade on PancakeSwap (pair ${t.pair})` : hint(t) })
109
+
110
+ /** hot = 24h USD volume on the curve; new = newest first; near-grad = closest to graduation. */
111
+ export function rank(tokens, sort, { limit = 10, minPct = 50, maxAgeSec = null, includeGraduated = false } = {}) {
112
+ let list = includeGraduated ? tokens : tokens.filter((t) => !t.graduated)
113
+ if (sort === 'hot') {
114
+ // graduated rows carry DEX volume with trades24h = 0, so volume alone also qualifies
115
+ list = list.filter((t) => t.trades24h > 0 || t.volume24hUsd > 0)
116
+ .sort((a, b) => (b.volume24hUsd - a.volume24hUsd) || (b.trades24h - a.trades24h))
117
+ } else if (sort === 'new') {
118
+ const t0 = now()
119
+ if (maxAgeSec != null) list = list.filter((t) => t0 - t.createdAt <= maxAgeSec)
120
+ list.sort((a, b) => b.createdAt - a.createdAt)
121
+ } else if (sort === 'near-grad') {
122
+ list = list.filter((t) => !t.graduated && t.progressPct >= minPct)
123
+ .sort((a, b) => (b.progressPct - a.progressPct) || ((a.remainingUsd ?? Infinity) - (b.remainingUsd ?? Infinity)))
124
+ }
125
+ return list.slice(0, limit).map(withHint)
126
+ }
127
+
128
+ async function tokenDetail(addr, stop) {
129
+ const r = await get(`/tokens/${addr.toLowerCase()}`, stop)
130
+ if (!r) throw new Error(`token not indexed by ${API}: ${addr}. On-chain view: \`gang token --token ${addr}\``)
131
+ return normalize(r)
132
+ }
133
+
134
+ /** One page of trades, newest first. */
135
+ async function tradePage(t, offset = 0, stop) {
136
+ const base = t.track === 'v2' ? '/stocks/tokens' : '/tokens'
137
+ const page = await get(`${base}/${t.address}/trades?limit=${TRADE_PAGE}&offset=${offset}`, stop)
138
+ const items = (page?.items || []).map((x) => ({
139
+ side: x.side, trader: x.trader, traderHandle: x.traderHandle || null,
140
+ amount: num(x.quoteIn ?? x.bnb), quote: t.quote, tokens: num(x.tokens),
141
+ price: num(x.price), at: x.ts, tx: x.tx,
142
+ }))
143
+ return { items, next: page?.hasMore && page.nextOffset != null ? page.nextOffset : null }
144
+ }
145
+
146
+ const tradeKey = (x) => `${x.tx}:${x.side}:${x.trader}:${x.tokens}`
147
+
148
+ /**
149
+ * Trades not in `seen`, oldest first. Pages back until a page contains a known trade; `gap` = gave up
150
+ * after TRADE_PAGES pages of only-unseen trades, so older ones in between were skipped.
151
+ */
152
+ async function freshTrades(t, seen, stop) {
153
+ const fresh = []
154
+ const local = new Set() // offsets shift when trades land mid-pagination → same row on two pages
155
+ for (let offset = 0, i = 0; i < TRADE_PAGES; i++) {
156
+ const { items, next } = await tradePage(t, offset, stop)
157
+ let hit = false
158
+ for (const x of items) {
159
+ const k = tradeKey(x)
160
+ if (seen.has(k)) hit = true
161
+ else if (!local.has(k)) { local.add(k); fresh.push(x) }
162
+ }
163
+ if (hit || next == null) return { fresh: fresh.reverse(), gap: false }
164
+ offset = next
165
+ }
166
+ return { fresh: fresh.reverse(), gap: true }
167
+ }
168
+
169
+ const crossed = (prev, cur, levels) => levels.filter((m) => prev < m && cur >= m)
170
+ const brief = (t) => ({
171
+ token: t.address, symbol: t.symbol, track: t.track, quote: t.quote, progressPct: t.progressPct,
172
+ remaining: t.remaining, remainingUsd: t.remainingUsd, volume24hUsd: t.volume24hUsd, holders: t.holders,
173
+ })
174
+
175
+ /**
176
+ * Poll the API and call emit(event) for each change. Token mode: trade / progress / graduated.
177
+ * Market mode: new_token / near_grad (crossing minPct upward) / graduated.
178
+ * The first tick only snapshots state; current matches come from `gang near-grad` / `gang new`.
179
+ * `stop` (AbortSignal) cancels in-flight requests; errors after shouldStop() flips are dropped, not emitted.
180
+ */
181
+ export async function watch({ token, intervalSec = 10, minPct = 50, track = 'all', events = null, emit, shouldStop, stop }) {
182
+ const want = (e) => !events || events.has(e)
183
+ // wakes early once shouldStop() flips (e.g. --timeout) instead of overshooting a full interval
184
+ const sleep = async (s) => {
185
+ const end = Date.now() + s * 1000
186
+ while (Date.now() < end && !shouldStop()) await new Promise((r) => setTimeout(r, Math.min(1000, end - Date.now())))
187
+ }
188
+
189
+ if (token) {
190
+ // graduation always ends a token watch; when --events filters it out, end with a meta line instead
191
+ const finish = (t) => (want('graduated')
192
+ ? emit({ event: 'graduated', ...brief(t), pair: t.pair })
193
+ : emit({ event: 'ended', reason: 'graduated', token: t.address, pair: t.pair }, { meta: true }))
194
+ let t = await tokenDetail(token, stop)
195
+ const seen = new Set((await tradePage(t, 0, stop)).items.map(tradeKey))
196
+ const remember = (k) => {
197
+ seen.add(k)
198
+ if (seen.size > SEEN_CAP) seen.delete(seen.values().next().value) // Set keeps insertion order → drops oldest
199
+ }
200
+ emit({ event: 'watching', mode: 'token', ...brief(t), graduated: t.graduated, intervalSec }, { meta: true })
201
+ if (t.graduated) return finish(t)
202
+ for (;;) {
203
+ await sleep(intervalSec)
204
+ if (shouldStop()) return
205
+ try {
206
+ const cur = await tokenDetail(token, stop)
207
+ const { fresh, gap } = await freshTrades(cur, seen, stop)
208
+ // advance state only after both reads succeed, so a failed tick can't swallow a prev → cur transition
209
+ const prev = t
210
+ t = cur
211
+ if (gap) emit({ event: 'error', error: `more than ${TRADE_PAGE * TRADE_PAGES} trades since last poll; older ones skipped` }, { meta: true })
212
+ for (const x of fresh) {
213
+ remember(tradeKey(x))
214
+ if (want('trade')) emit({ event: 'trade', token: t.address, symbol: t.symbol, ...x, progressPct: t.progressPct })
215
+ if (shouldStop()) return
216
+ }
217
+ for (const m of crossed(prev.progressPct, t.progressPct, MILESTONES)) {
218
+ if (want('progress')) emit({ event: 'progress', milestone: m, ...brief(t), next: hint(t) })
219
+ if (shouldStop()) return
220
+ }
221
+ if (!prev.graduated && t.graduated) return finish(t)
222
+ } catch (e) {
223
+ if (shouldStop()) return
224
+ emit({ event: 'error', error: e.message }, { meta: true })
225
+ }
226
+ if (shouldStop()) return
227
+ }
228
+ }
229
+
230
+ const first = await fetchMarket(track, stop)
231
+ // only grows: a token missing from one poll must not come back as new_token
232
+ const known = new Map(first.tokens.map((t) => [t.address, t]))
233
+ emit({ event: 'watching', mode: 'market', track, tokens: known.size, minPct, intervalSec }, { meta: true })
234
+ let warned = false
235
+ const warnTruncated = (truncated) => {
236
+ if (!truncated || warned) return
237
+ warned = true
238
+ emit({ event: 'error', error: `market list capped at ${MARKET_PAGES * 200} tokens per track; tokens beyond the cap are not watched` }, { meta: true })
239
+ }
240
+ warnTruncated(first.truncated)
241
+ for (;;) {
242
+ await sleep(intervalSec)
243
+ if (shouldStop()) return
244
+ try {
245
+ const { tokens, truncated } = await fetchMarket(track, stop)
246
+ warnTruncated(truncated)
247
+ for (const t of tokens) {
248
+ const prev = known.get(t.address)
249
+ known.set(t.address, t)
250
+ const out = []
251
+ if (!prev) out.push({ event: 'new_token', ...withHint(t) })
252
+ else if (!prev.graduated && t.graduated) out.push({ event: 'graduated', ...brief(t), pair: t.pair })
253
+ else if (!t.graduated && prev.progressPct < minPct && t.progressPct >= minPct) {
254
+ out.push({ event: 'near_grad', ...brief(t), next: hint(t) })
255
+ }
256
+ for (const ev of out) {
257
+ if (want(ev.event)) emit(ev)
258
+ if (shouldStop()) return
259
+ }
260
+ }
261
+ } catch (e) {
262
+ if (shouldStop()) return
263
+ emit({ event: 'error', error: e.message }, { meta: true })
264
+ }
265
+ }
266
+ }