@gangfun/cli 0.2.1 → 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/README.md CHANGED
@@ -10,15 +10,10 @@ Node ≥ 18. Also install Binance Agentic Wallet CLI (`baw`):
10
10
 
11
11
  ```bash
12
12
  npm i -g @binance/agentic-wallet
13
- npm i -g @gangfun/cli@0.2.1 --registry=https://npm.pkg.github.com
13
+ npm i -g @gangfun/cli@latest
14
14
  ```
15
15
 
16
- Package: [GitHub Packages `@gangfun/cli`](https://github.com/gangfun/cli/pkgs/npm/cli). Auth:
17
-
18
- ```ini
19
- @gangfun:registry=https://npm.pkg.github.com
20
- //npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN
21
- ```
16
+ Package: [`@gangfun/cli` on npm](https://www.npmjs.com/package/@gangfun/cli) (public registry, no auth). Upgrade with the same command.
22
17
 
23
18
  **Alternate** (public git tip):
24
19
 
@@ -44,6 +39,10 @@ npx skills add gangfun/gang-launchpad/skill
44
39
  ```bash
45
40
  gang preflight
46
41
  gang list
42
+ gang hot [--limit 10] [--track all|v2|soft] [--include-graduated] # 24h USD volume
43
+ gang new [--limit 10] [--max-age 2h]
44
+ gang near-grad [--min-pct 50] [--limit 10]
45
+ gang watch [<addr>] [--interval 10] [--events a,b] [--max-events N] [--timeout 30m] [--webhook URL]
47
46
  gang token --token <addr>
48
47
  gang quote --token <addr> --bnb <n> # Soft archive: BNB; V2: quote ERC-20 (LaunchpadV2.sol math)
49
48
  gang buy --token <addr> --bnb <n> [--slippage 1]
@@ -74,6 +73,20 @@ gang execute --request-id …
74
73
 
75
74
  Buy / sell / create: **preview → you confirm → execute**. `requestId` expires in ~5 minutes.
76
75
 
76
+ ### Discovery (0.3.0+)
77
+
78
+ `hot` / `new` / `near-grad` / `watch` read the gang.fun indexer (`GANG_API`) and cover the V2 and Soft tracks. Each row has
79
+ `quote` (the unit to pass to `--bnb`), `progressPct`, `remaining`/`remainingUsd`, `volume24hUsd`, `holders`, `ageMin` and a `next` command.
80
+ Graduated tokens are hidden unless `--include-graduated` is passed.
81
+
82
+ `gang watch` polls and prints NDJSON. The minimum interval is 3s with `<addr>` and 10s for the whole market. With `<addr>` it emits `trade` / `progress` (25/50/75/90%) / `graduated`.
83
+ Without it, it emits `new_token` / `near_grad` (crosses `--min-pct`) / `graduated`. `--max-events 1` blocks until the next event and
84
+ `--webhook` POSTs each event. Details: [skill/references/discover.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/discover.md).
85
+
86
+ ```bash
87
+ gang watch --events new_token,near_grad --max-events 1 --timeout 30m
88
+ ```
89
+
77
90
  ## Quote path
78
91
 
79
92
  - **Soft archive tokens:** on-chain `quoteBuy` / `quoteSell`.
@@ -94,7 +107,8 @@ Buy / sell / create: **preview → you confirm → execute**. `requestId` expire
94
107
  | `GANG_LAUNCHPAD` | Create target (default LaunchpadV2 `0x1B8F3f69…966E`) |
95
108
  | `GANG_RPC_URL` | BSC HTTP RPC |
96
109
  | `GANG_LOGO_API` | Logo upload endpoint (default `https://gang.fun/api/logos`) |
110
+ | `GANG_API` | Indexer API for discovery (default `https://gang.fun/api`) |
97
111
 
98
112
  ## Version
99
113
 
100
- `0.2.1` · repo [gangfun/cli](https://github.com/gangfun/cli) · distribute via [GitHub Packages](https://github.com/gangfun/cli/pkgs/npm/cli) (not npmjs)
114
+ `0.3.0` · repo [gangfun/cli](https://github.com/gangfun/cli) · published to [npm](https://www.npmjs.com/package/@gangfun/cli) on push to `main`
package/README.zh-CN.md CHANGED
@@ -10,10 +10,10 @@ Node ≥ 18。另需 `baw`:
10
10
 
11
11
  ```bash
12
12
  npm i -g @binance/agentic-wallet
13
- npm i -g @gangfun/cli@0.2.1 --registry=https://npm.pkg.github.com
13
+ npm i -g @gangfun/cli@latest
14
14
  ```
15
15
 
16
- 或:`npm i -g github:gangfun/cli`
16
+ npm 公共源,无需鉴权;升级用同一条命令。或(git 最新):`npm i -g github:gangfun/cli`
17
17
 
18
18
  Skill:`npx skills add gangfun/gang-launchpad/skill`
19
19
 
@@ -29,6 +29,10 @@ Skill:`npx skills add gangfun/gang-launchpad/skill`
29
29
  ```bash
30
30
  gang preflight
31
31
  gang list
32
+ gang hot [--limit 10] [--track all|v2|soft] [--include-graduated] # 按 24h USD 成交量
33
+ gang new [--limit 10] [--max-age 2h]
34
+ gang near-grad [--min-pct 50] [--limit 10]
35
+ gang watch [<addr>] [--interval 10] [--events a,b] [--max-events N] [--timeout 30m] [--webhook URL]
32
36
  gang token --token <addr>
33
37
  gang quote --token <addr> --bnb <n>
34
38
  gang buy --token <addr> --bnb <n> [--slippage 1]
@@ -53,6 +57,14 @@ gang create --name "My Agent Coin" --symbol MAC --logo ./avatar.png
53
57
  gang execute --request-id …
54
58
  ```
55
59
 
60
+ ### 发现(0.3.0+)
61
+
62
+ `hot` / `new` / `near-grad` / `watch` 读 gang.fun indexer(`GANG_API`),同时覆盖 V2 与 Soft。每行带 `quote`(`--bnb` 的单位)、
63
+ `progressPct`、`remaining`/`remainingUsd`、`volume24hUsd`、`holders`、`ageMin` 和 `next` 命令;已毕业默认隐藏(`--include-graduated` 显示)。
64
+
65
+ `gang watch` 轮询并输出 NDJSON(最短间隔:单币 3s,全市场 10s):带 `<addr>` 推 `trade` / `progress`(25/50/75/90%)/ `graduated`;不带则推 `new_token` / `near_grad`(越过 `--min-pct`)/ `graduated`。
66
+ `--max-events 1` = 阻塞到下一个事件;`--webhook` 把每个事件 POST 出去。详见 [skill/references/discover.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/discover.md)。
67
+
56
68
  ## Quote 路径
57
69
 
58
70
  - Soft 存量:链上 `quoteBuy` / `quoteSell`。
@@ -71,7 +83,8 @@ gang execute --request-id …
71
83
  | `GANG_LAUNCHPAD` | 发币目标(默认 V2) |
72
84
  | `GANG_RPC_URL` | BSC RPC |
73
85
  | `GANG_LOGO_API` | Logo 上传 API(默认 `https://gang.fun/api/logos`) |
86
+ | `GANG_API` | 发现命令用的 indexer API(默认 `https://gang.fun/api`) |
74
87
 
75
88
  ## 版本
76
89
 
77
- `0.2.1` · [gangfun/cli](https://github.com/gangfun/cli)
90
+ `0.3.0` · [gangfun/cli](https://github.com/gangfun/cli) · push 到 `main` 自动发布到 [npm](https://www.npmjs.com/package/@gangfun/cli)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gangfun/cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Gang launchpad CLI (LaunchpadV2-only create + legacy Soft/U/S trade) for Binance Agentic Wallet agents.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/chain.js CHANGED
@@ -57,7 +57,7 @@ export async function resolvePad(token) {
57
57
  }
58
58
  throw new Error(
59
59
  `not a Gang token on known pads (V2/Soft/U/S): ${token}. ` +
60
- `Upgrade: npm i -g @gangfun/cli@0.2.1 --registry=https://npm.pkg.github.com OR npm i -g github:gangfun/cli`,
60
+ `Upgrade: npm i -g @gangfun/cli@latest OR npm i -g github:gangfun/cli`,
61
61
  )
62
62
  }
63
63
 
@@ -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
+ }
package/src/index.js CHANGED
@@ -5,13 +5,19 @@ import {
5
5
  } from './chain.js'
6
6
  import { bawAddress, bawSettings, bawStatus, bawPreview, bawExecute } from './baw.js'
7
7
  import { uploadLogoFile, LOGO_UI } from './logo.js'
8
+ import { API, fetchMarket, rank, watch, parseDuration } from './discover.js'
8
9
 
9
10
  const args = process.argv.slice(2)
10
11
  const cmd = args[0]
11
12
  const opt = (name, def) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : def }
12
13
  const flag = (name) => args.includes(`--${name}`)
13
14
  const out = (o) => console.log(JSON.stringify(o, (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2))
14
- const fail = (msg, extra = {}) => { out({ success: false, error: msg, ...extra }); process.exit(1) }
15
+ // `gang watch` speaks NDJSON, so its failures are one line too (with event/fatal for stream consumers)
16
+ const fail = (msg, extra = {}) => {
17
+ if (cmd === 'watch') console.log(JSON.stringify({ event: 'error', fatal: true, success: false, error: msg, ...extra }))
18
+ else out({ success: false, error: msg, ...extra })
19
+ process.exit(1)
20
+ }
15
21
 
16
22
  const parseSlippage = (raw) => {
17
23
  const s = Number(raw)
@@ -19,10 +25,29 @@ const parseSlippage = (raw) => {
19
25
  return s
20
26
  }
21
27
 
28
+ const parseCount = (name, raw, max) => {
29
+ const n = Number(raw)
30
+ if (!Number.isInteger(n) || n < 1 || n > max) fail(`--${name} must be an integer between 1 and ${max}`, { got: raw })
31
+ return n
32
+ }
33
+ const parsePct = (raw) => {
34
+ const n = Number(raw)
35
+ if (!Number.isFinite(n) || n < 0 || n > 100) fail('--min-pct must be a number between 0 and 100', { got: raw })
36
+ return n
37
+ }
38
+ const WATCH_EVENTS = ['trade', 'progress', 'graduated', 'new_token', 'near_grad']
39
+
22
40
  const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet) · LaunchpadV2 create
23
41
 
24
42
  gang preflight check sign-in, Developer Mode, tradeAllTokens, Agent ID
25
43
  gang list tokens across V2 / Soft / U / S
44
+ gang hot [--limit 10] [--track all|v2|soft] [--include-graduated]
45
+ curve tokens ranked by 24h USD volume
46
+ gang new [--limit 10] [--max-age 2h] [--track …] newest curve tokens first
47
+ gang near-grad [--min-pct 50] [--limit 10] [--track …] closest to graduation
48
+ gang watch [<addr>] [--interval 10] [--min-pct 50] [--events a,b] [--max-events N] [--timeout 30m] [--webhook URL]
49
+ NDJSON stream (min interval: 3s token, 10s market). <addr>: trade / progress / graduated.
50
+ no addr: new_token / near_grad / graduated across the market
26
51
  gang token --token <addr> one token (auto-resolves pad)
27
52
  gang quote --token <addr> --bnb <n> Soft: BNB quote; V2/U/S: quote-token amount (same flag)
28
53
  gang buy --token <addr> --bnb <n> [--slippage 1] Soft: payable BNB; V2: approve quote then buy
@@ -34,7 +59,7 @@ const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet
34
59
  gang tx --hash <0x..> wait for receipt and decode Gang events
35
60
 
36
61
  Logo UI (browser): ${LOGO_UI}
37
- env: GANG_LAUNCHPAD (default V2 ${ADDR.v2}), GANG_RPC_URL, GANG_LOGO_API`
62
+ env: GANG_LAUNCHPAD (default V2 ${ADDR.v2}), GANG_RPC_URL, GANG_LOGO_API, GANG_API (default ${API})`
38
63
 
39
64
  function summarizePreview(p, extra = {}) {
40
65
  if (!p.success) return { success: false, step: extra.step, error: p.error }
@@ -87,6 +112,85 @@ async function main() {
87
112
  const me = bawStatus() === 'CONNECTED' ? bawAddress() : undefined
88
113
  return out({ success: true, createPad: ADDR.v2, tokens: await listTokens(me) })
89
114
  }
115
+ case 'hot':
116
+ case 'new':
117
+ case 'near-grad': {
118
+ const limit = parseCount('limit', opt('limit', '10'), 100)
119
+ const minPct = parsePct(opt('min-pct', '50'))
120
+ const maxAgeSec = opt('max-age') ? parseDuration(opt('max-age'), 'max-age') : null
121
+ const track = opt('track', 'all')
122
+ const includeGraduated = flag('include-graduated')
123
+ const market = await fetchMarket(track)
124
+ const tokens = rank(market.tokens, cmd, { limit, minPct, maxAgeSec, includeGraduated })
125
+ const empty = {
126
+ hot: includeGraduated ? 'No token traded in the last 24h.' : 'No curve token traded in the last 24h.',
127
+ new: maxAgeSec != null ? `No curve token created in the last ${opt('max-age')}.` : 'No curve tokens.',
128
+ 'near-grad': `No curve token at ≥${minPct}% of graduation; lower --min-pct to see the closest ones.`,
129
+ }[cmd]
130
+ return out({
131
+ success: true, source: API, sort: cmd, track, count: tokens.length,
132
+ ...(cmd === 'near-grad' ? { minPct } : {}), ...(maxAgeSec != null ? { maxAgeSec } : {}),
133
+ ...(market.truncated ? { truncated: true } : {}),
134
+ tokens,
135
+ note: tokens.length ? 'Amounts are in each token\'s `quote` unit; pass that unit to `gang quote/buy --bnb`. Data, not advice.' : empty,
136
+ })
137
+ }
138
+ case 'watch': {
139
+ const positional = args[1] && !args[1].startsWith('--') ? args[1] : null
140
+ if (positional && flag('token')) fail('give the token either as <addr> or via --token, not both')
141
+ if (!positional && flag('token') && !opt('token')) fail('--token needs an address (or omit it to watch the whole market)')
142
+ const token = positional || opt('token')
143
+ if (token && !/^0x[0-9a-fA-F]{40}$/.test(token)) fail('watch: token must be a 0x… address', { got: token })
144
+ // market mode pulls the full token list every tick, so it gets a higher floor than a single token
145
+ const intervalSec = Math.max(token ? 3 : 10, parseDuration(opt('interval', '10'), 'interval'))
146
+ const maxEvents = opt('max-events') ? parseCount('max-events', opt('max-events'), 1e6) : Infinity
147
+ const deadline = opt('timeout') ? Date.now() + parseDuration(opt('timeout'), 'timeout') * 1000 : Infinity
148
+ const minPct = parsePct(opt('min-pct', '50'))
149
+ const webhook = opt('webhook')
150
+ if (webhook && !/^https?:\/\//.test(webhook)) fail('--webhook must be an http(s) URL', { got: webhook })
151
+ let events = null
152
+ if (opt('events')) {
153
+ events = new Set(opt('events').split(',').map((s) => s.trim().replace(/-/g, '_')).filter(Boolean))
154
+ const bad = [...events].filter((e) => !WATCH_EVENTS.includes(e))
155
+ if (bad.length) fail(`unknown --events: ${bad.join(', ')}`, { allowed: WATCH_EVENTS })
156
+ }
157
+
158
+ let count = 0
159
+ const hooks = new Set()
160
+ const line = (o) => console.log(JSON.stringify({ ...o, emittedAt: Math.floor(Date.now() / 1000) }))
161
+ // meta events (watching / error / ended / timeout) go to stdout only and do not count toward --max-events
162
+ const emit = (ev, { meta = false } = {}) => {
163
+ line(ev)
164
+ if (meta) return
165
+ count++
166
+ if (webhook) {
167
+ const p = fetch(webhook, {
168
+ method: 'POST', headers: { 'content-type': 'application/json' },
169
+ body: JSON.stringify(ev), signal: AbortSignal.timeout(10_000),
170
+ }).then((r) => { if (!r.ok) line({ event: 'error', error: `webhook ${r.status}` }) })
171
+ .catch((e) => line({ event: 'error', error: `webhook: ${e.message}` }))
172
+ .finally(() => hooks.delete(p))
173
+ hooks.add(p)
174
+ }
175
+ }
176
+ // aborts in-flight API requests at the deadline so --timeout isn't stretched by a slow API.
177
+ // setTimeout caps at 2^31-1 ms (~24.8d) and fires at once beyond it; longer timeouts rely on sleep's check.
178
+ const stopper = new AbortController()
179
+ const delay = deadline - Date.now()
180
+ const deadlineTimer = delay <= 2 ** 31 - 1 ? setTimeout(() => stopper.abort(), delay) : null
181
+ // the timer runs on the monotonic clock and may beat Date.now() by a hair; treat its firing as the deadline
182
+ const shouldStop = () => count >= maxEvents || stopper.signal.aborted || Date.now() >= deadline
183
+ try {
184
+ await watch({ token, intervalSec, minPct, track: opt('track', 'all'), events, emit, shouldStop, stop: stopper.signal })
185
+ } catch (e) {
186
+ if (!shouldStop()) throw e // startup failure → fail() prints it as one NDJSON line
187
+ } finally {
188
+ clearTimeout(deadlineTimer)
189
+ }
190
+ if (count < maxEvents && (stopper.signal.aborted || Date.now() >= deadline)) line({ event: 'timeout', events: count })
191
+ await Promise.allSettled([...hooks])
192
+ return
193
+ }
90
194
  case 'token': {
91
195
  const token = opt('token'); if (!token) fail('--token required')
92
196
  const me = bawStatus() === 'CONNECTED' ? bawAddress() : undefined