@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 +22 -8
- package/README.zh-CN.md +16 -3
- package/package.json +1 -1
- package/src/chain.js +1 -1
- package/src/discover.js +266 -0
- package/src/index.js +106 -2
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@
|
|
13
|
+
npm i -g @gangfun/cli@latest
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
Package: [
|
|
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.
|
|
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@
|
|
13
|
+
npm i -g @gangfun/cli@latest
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
|
|
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.
|
|
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
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@
|
|
60
|
+
`Upgrade: npm i -g @gangfun/cli@latest OR npm i -g github:gangfun/cli`,
|
|
61
61
|
)
|
|
62
62
|
}
|
|
63
63
|
|
package/src/discover.js
ADDED
|
@@ -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
|
-
|
|
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
|