@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/README.md +59 -32
- package/README.zh-CN.md +50 -47
- package/package.json +2 -2
- package/src/abi.js +45 -3
- package/src/chain.js +238 -47
- package/src/discover.js +266 -0
- package/src/index.js +228 -28
- package/src/logo.js +40 -0
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 {
|
|
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 ||
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
`
|
|
28
|
-
`
|
|
29
|
-
`
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
44
|
-
info.myBalance = fmtTok(bal); info.myBalanceRaw = bal.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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
63
|
-
|
|
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
|
|
247
|
+
export async function allowance(token, owner, spender) {
|
|
248
|
+
return erc(token, 'allowance', [owner, spender])
|
|
249
|
+
}
|
|
69
250
|
|
|
70
251
|
export const calldata = {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
266
|
+
const pad = known.get(log.address.toLowerCase())
|
|
267
|
+
if (!pad) continue
|
|
82
268
|
try {
|
|
83
|
-
const ev = decodeEventLog({ abi:
|
|
84
|
-
const a = Object.fromEntries(Object.entries(ev.args).map(([k, v]) =>
|
|
85
|
-
|
|
86
|
-
|
|
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
|
}
|
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
|
+
}
|