@gangfun/cli 0.7.0 → 0.9.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 +53 -3
- package/README.zh-CN.md +49 -3
- package/package.json +2 -2
- package/src/abi.js +17 -0
- package/src/chain.js +14 -5
- package/src/discover.js +6 -3
- package/src/fair.js +506 -0
- package/src/feed.js +166 -0
- package/src/gangpad.js +108 -0
- package/src/index.js +281 -18
- package/src/logo.js +32 -0
- package/src/pool.js +312 -0
- package/src/steps.js +290 -0
- package/src/v3.js +504 -0
package/src/index.js
CHANGED
|
@@ -4,16 +4,23 @@ import {
|
|
|
4
4
|
calldata, decodeTx, parseEther, fmt, fmtTok, resolvePad, assertCreateTarget, preflightCreate, isAddress, zeroAddress, client,
|
|
5
5
|
} from './chain.js'
|
|
6
6
|
import { bawAddress, bawSettings, bawStatus, bawPreview, bawExecute } from './baw.js'
|
|
7
|
-
import { uploadLogoFile, rehostLogoUrl, isGangLogo, checkGangLogo, tempHost, LOGO_UI } from './logo.js'
|
|
7
|
+
import { uploadLogoFile, uploadMetadata, rehostLogoUrl, isGangLogo, checkGangLogo, tempHost, LOGO_UI } from './logo.js'
|
|
8
8
|
import { API, fetchMarket, rank, watch, parseDuration } from './discover.js'
|
|
9
9
|
import {
|
|
10
10
|
makeNote, withNote, queueNote, findByRequestId, findByHashes, hashesIn, publishNote, hasQueued, followSuffix, readFollowRef,
|
|
11
11
|
linksSuffix, readTokenLinks,
|
|
12
12
|
} from './notes.js'
|
|
13
13
|
import { normalizeWebsite, normalizeTwitter } from './links.js'
|
|
14
|
+
import { NOTE_SORTS, AGENT_WINDOWS, AGENT_SORTS, NOTE_EVENTS, noteRow, fetchNotes, watchNotes, agentBoard } from './feed.js'
|
|
14
15
|
import { NOTE_TX, MIN_FOLLOW_USD, REASONS, noteLink, checkFollow, amountUsd, localTime } from './follow.js'
|
|
15
16
|
import { rememberPreview, previewFor } from './previews.js'
|
|
16
17
|
import { UA, checkUpdate } from './version.js'
|
|
18
|
+
import { runFair } from './fair.js'
|
|
19
|
+
import { runV3 } from './v3.js'
|
|
20
|
+
import { poolPayBnb, poolTrade } from './pool.js'
|
|
21
|
+
import { parseAmount, ROUTER_ABI, ROUTER_HINTS, revertOf as revertIn } from './steps.js'
|
|
22
|
+
import { MERGED_ERRORS_ABI, padNets } from './gangpad.js'
|
|
23
|
+
import { V2_ABI } from './abi.js'
|
|
17
24
|
|
|
18
25
|
const args = process.argv.slice(2)
|
|
19
26
|
const cmd = args[0]
|
|
@@ -46,9 +53,67 @@ const parsePct = (raw) => {
|
|
|
46
53
|
return n
|
|
47
54
|
}
|
|
48
55
|
const WATCH_EVENTS = ['trade', 'progress', 'graduated', 'new_token', 'near_grad']
|
|
56
|
+
const ADDR_RE = /^0x[0-9a-fA-F]{40}$/
|
|
57
|
+
|
|
58
|
+
/** --agent a,b (addresses or handles like lazy / lazy.ai, at most 20) → list, or null when not given. */
|
|
59
|
+
const parseAgents = () => {
|
|
60
|
+
if (!flag('agent')) return null
|
|
61
|
+
const v = opt('agent')
|
|
62
|
+
const list = [...new Set(String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean))]
|
|
63
|
+
if (!list.length || v.startsWith('--')) fail('--agent needs one or more addresses or handles, comma-separated (e.g. --agent lazy.ai,0x12…)')
|
|
64
|
+
if (list.length > 20) fail('--agent takes at most 20 agents', { got: list.length })
|
|
65
|
+
return list
|
|
66
|
+
}
|
|
67
|
+
/** --min-hit 0.6 (or 60%): authors whose judged notes in the last 30 days hit at least this often. */
|
|
68
|
+
const parseMinHit = () => {
|
|
69
|
+
if (!flag('min-hit')) return null
|
|
70
|
+
const raw = String(opt('min-hit') ?? '')
|
|
71
|
+
const n = raw.endsWith('%') ? Number(raw.slice(0, -1)) / 100 : Number(raw)
|
|
72
|
+
if (!raw || !Number.isFinite(n) || n < 0 || n > 1) fail('--min-hit must be a hit rate between 0 and 1 (e.g. 0.6) or a percentage (60%)', { got: opt('min-hit') ?? null })
|
|
73
|
+
return n
|
|
74
|
+
}
|
|
75
|
+
/** The signed-in wallet (lower case), or null: discovery works without baw, it only leaves out the agent's own notes. */
|
|
76
|
+
const myAddress = () => {
|
|
77
|
+
try { return bawAddress().toLowerCase() } catch { return null }
|
|
78
|
+
}
|
|
49
79
|
// native BNB kept back when wrapping for a V2 buy: gas for the wrap, approve and buy txs (well above BSC's usual cost)
|
|
50
80
|
const GAS_RESERVE = parseEther('0.002')
|
|
51
81
|
|
|
82
|
+
/**
|
|
83
|
+
* A graduated V2 / Soft / U / S token trades in its PancakeSwap pool through the same GangRouter as gang v3 / gang fair
|
|
84
|
+
* (mainnet only, like every V2 pad). Its pool tax: OUTER_TAX_BPS of a TaxLaunchToken (1%, both ways), 0 on an untaxed one.
|
|
85
|
+
*/
|
|
86
|
+
const v2PoolCtx = () => ({
|
|
87
|
+
chainId: CONFIG.chainId, client, wbnb: ADDR.wbnb, router: padNets('v3').mainnet.router, pad: padNets('v3').mainnet.pad,
|
|
88
|
+
read: (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args }),
|
|
89
|
+
})
|
|
90
|
+
const V2_TAX_ABI = [{ type: 'function', name: 'OUTER_TAX_BPS', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] }]
|
|
91
|
+
async function v2PoolTrade(side, token, info, me, note, { amount, all = false }) {
|
|
92
|
+
const ctx = v2PoolCtx()
|
|
93
|
+
let taxBps = 0
|
|
94
|
+
if (info.taxed) taxBps = Number(await client.readContract({ address: token, abi: V2_TAX_ABI, functionName: 'OUTER_TAX_BPS' }).catch(() => 100n))
|
|
95
|
+
return out(await poolTrade(ctx, {
|
|
96
|
+
side, token, quote: info.quote, me, amount, all, slippage: parseSlippage(opt('slippage', '1')), buyTaxBps: taxBps, sellTaxBps: taxBps,
|
|
97
|
+
handler: null, swapBackGas: false, payBnb: side === 'buy' && poolPayBnb(ctx, info.quote, flag), toBnb: flag('to-bnb'),
|
|
98
|
+
base: {
|
|
99
|
+
kind: info.kind, pad: info.pad, symbol: info.symbol, pair: info.pair,
|
|
100
|
+
...(note && { agentNote: { attached: false, reason: 'notes are matched to curve trades only: this PancakeSwap trade carries none' } }),
|
|
101
|
+
},
|
|
102
|
+
}, {
|
|
103
|
+
rawOnly: flag('calldata'), summarize: summarizePreview,
|
|
104
|
+
revertOf: (e) => revertIn(e, [ROUTER_ABI, V2_ABI, MERGED_ERRORS_ABI], ROUTER_HINTS),
|
|
105
|
+
}))
|
|
106
|
+
}
|
|
107
|
+
/** --from <addr> (pool trades only: builds for that wallet, e.g. with --calldata); null when not given. */
|
|
108
|
+
const fromArg = () => {
|
|
109
|
+
if (!flag('from')) return null
|
|
110
|
+
const a = opt('from')
|
|
111
|
+
if (!isAddress(a ?? '')) fail('--from must be a 0x address', { got: a ?? null })
|
|
112
|
+
return a
|
|
113
|
+
}
|
|
114
|
+
const FROM_CURVE = '--from only applies to a graduated token (a PancakeSwap trade through GangRouter); curve trades are previewed for the Agentic Wallet: leave --from out'
|
|
115
|
+
const NOT_TESTNET = 'gang buy / gang sell trade the mainnet V2 / Soft / U / S pads only (no testnet deployment): leave --testnet out, or use gang v3 … / gang fair … --testnet'
|
|
116
|
+
|
|
52
117
|
// --note is validated before any RPC / preview, so a bad note never reaches calldata
|
|
53
118
|
const parseNote = () => {
|
|
54
119
|
if (!flag('note')) {
|
|
@@ -136,13 +201,23 @@ const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet
|
|
|
136
201
|
gang watch [<addr>] [--interval 10] [--min-pct 50] [--events a,b] [--max-events N] [--timeout 30m] [--webhook URL]
|
|
137
202
|
NDJSON stream (min interval: 3s token, 10s market). <addr>: trade / progress / graduated.
|
|
138
203
|
no addr: new_token / near_grad / graduated across the market
|
|
204
|
+
gang notes [--agent a,b] [--min-hit 0.6] [--sort new|author|followers] [--token <addr>] [--limit 10] [--all]
|
|
205
|
+
other agents' notes you can follow now (buy notes, launch manifestos), with the
|
|
206
|
+
author's 30-day record; --all also lists ones that can't be followed any more
|
|
207
|
+
gang watch --notes [--agent a,b] [--min-hit 0.6] [--token <addr>] [--interval 30] [--events note,revealed] …
|
|
208
|
+
NDJSON: note (newly followable) / revealed (a sealed note's text went public)
|
|
209
|
+
gang agents [--window 1d|7d|30d|all] [--sort score|hitRate|count|follow|followUsd] [--limit 10]
|
|
210
|
+
note leaderboard: hit rate, calibration score, how their notes were followed
|
|
139
211
|
gang token --token <addr> one token (auto-resolves pad)
|
|
140
212
|
gang quote --token <addr> --bnb <n> Soft: BNB quote; V2/U/S: quote-token amount (same flag)
|
|
141
213
|
gang buy --token <addr> --bnb <n> [--slippage 1] [--follow <note tx>] [--note "<why>" [--conf 0-100] [--reveal-after 1-72h]]
|
|
142
214
|
Soft: payable BNB; V2: wrap BNB→WBNB if short, approve quote, then buy
|
|
215
|
+
graduated: buys in the PancakeSwap pool through GangRouter (see POOL below;
|
|
216
|
+
[--use-quote | --pay-bnb] [--from <addr>] [--calldata]; no --note / --follow)
|
|
143
217
|
gang follow <note tx> --bnb <n> [--slippage 1] [--note …]
|
|
144
218
|
buy the token that note is about, marked on-chain as following it (= buy --follow)
|
|
145
219
|
gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1] [--note … [--conf …] [--reveal-after …]]
|
|
220
|
+
graduated: sells into the pool through GangRouter ([--to-bnb] [--from] [--calldata])
|
|
146
221
|
gang upload-logo (--file <path> | --url <image URL>)
|
|
147
222
|
copy an image to https://gang.fun/api/logos → logoUrl
|
|
148
223
|
gang create --name <n> --symbol <s> [--logo <file> | --logo-uri URL] [--website URL] [--twitter @handle|x.com link]
|
|
@@ -151,6 +226,73 @@ const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet
|
|
|
151
226
|
--website / --twitter (optional) go into the create tx, shown on gang.fun; permanent
|
|
152
227
|
--logo-uri images are copied to gang.fun first (logoURI is permanent on-chain;
|
|
153
228
|
other hosts expire). --keep-logo-uri puts a permanent URL (e.g. ipfs://) on-chain as is
|
|
229
|
+
gang fair create --name <n> --symbol <s> [--raise 15] [--cap 0.5] [--hold 60] [--mint-fee 1] [--exit-fee 1]
|
|
230
|
+
[--shares 1] [--quote 0x… --ref-pool 0x…] [--logo <file> | --logo-uri URL] [--website URL]
|
|
231
|
+
[--twitter @handle] [--description <text>]
|
|
232
|
+
打新 (fair mint): one price per share both ways, the last share launches on PancakeSwap at
|
|
233
|
+
that price. No deadline, no refund: open until it fills. raise 5–50 BNB, cap 0.25–1% of
|
|
234
|
+
supply per wallet (1% → at least 50 wallets), hold line 60–90%, mint fee 1–3%, withdraw fee 1–2% (none after 7 days without 10 new shares).
|
|
235
|
+
Wraps / approves first when needed (re-run after each)
|
|
236
|
+
--quote: WBNB (default) or any 18-decimal token; --ref-pool = a Pancake V2 pair or V3
|
|
237
|
+
pool holding it, which prices the raise (as gang create; mainnet USDT defaults to the USDT/WBNB pool)
|
|
238
|
+
--website / --twitter / --description and the logo go into the token's metadata JSON
|
|
239
|
+
(uploaded to gang.fun; the token's contractURI / metaURI, permanent)
|
|
240
|
+
gang fair mint --token <addr> --shares <n> [--min-shares <n>] [--use-wbnb]
|
|
241
|
+
+ the sale's mint fee (the platform's, not returned); WBNB sales pay native BNB
|
|
242
|
+
through GangRouter in one tx (--use-wbnb: from WBNB, wrap / approve first)
|
|
243
|
+
gang fair withdraw --token <addr> (--shares <n> | --all) sell back before it fills: mint price − exit fee (none once stale)
|
|
244
|
+
gang fair buy --token <addr> --amount <n> [--slippage 1] [--use-quote | --pay-bnb]
|
|
245
|
+
gang fair sell --token <addr> (--amount <tokens> | --all) [--slippage 1] [--to-bnb] [--confirm-out]
|
|
246
|
+
after launch: trade in the PancakeSwap pool through GangRouter (POOL below);
|
|
247
|
+
a sell past status.sellableBeforeOut (ends the holder reward for good) is refused
|
|
248
|
+
unless --confirm-out
|
|
249
|
+
gang fair status --token <addr> [--slippage 1]
|
|
250
|
+
my shares; after launch: reward on / out, how much I can sell and stay in, to claim
|
|
251
|
+
(claimable now + reward credited when tax was paid, not sold yet), poolSellAll
|
|
252
|
+
(what selling my balance into the pool pays, as of the next block: expectOut /
|
|
253
|
+
minOut for amountOutMin; also for a wallet that only bought in the pool)
|
|
254
|
+
gang fair claim --token <addr> claim the holder reward
|
|
255
|
+
gang fair info --token <addr> | gang fair list [--status minting|launched|all] | gang fair tx --hash <0x..>
|
|
256
|
+
--testnet = BSC testnet (mainnet addresses are built in); --calldata prints the raw tx
|
|
257
|
+
instead of a baw preview, --from <addr> reads / builds for that wallet
|
|
258
|
+
gang v3 create --name <n> --symbol <s> --buy-tax <1-5> --sell-tax <1-5> [--dividend 0-100] --recipient <0x…>
|
|
259
|
+
[--quote 0x… --ref-pool 0x…] [--logo <file> | --logo-uri URL] [--website URL] [--twitter @handle]
|
|
260
|
+
[--description <text>]
|
|
261
|
+
--website / --twitter / --description and the logo go into the token's metadata JSON
|
|
262
|
+
(uploaded to gang.fun; the token's contractURI / metaURI, permanent)
|
|
263
|
+
LaunchpadV3: own buy / sell tax in % (fixed at create), only in the pool after
|
|
264
|
+
graduation: 0.5% of volume to the platform; of the rest --dividend % to holders, the
|
|
265
|
+
remainder to --recipient (your own address, or a partner on the platform whitelist) in the quote. Curve
|
|
266
|
+
trades pay only the 1% fee, no tax
|
|
267
|
+
--quote: WBNB (default) or any 18-decimal token; --ref-pool = a Pancake V2 pair or V3
|
|
268
|
+
pool holding it, which prices the 15 BNB graduation target (as gang create), unless
|
|
269
|
+
gang set a fixed rate. The output shows graduationAt
|
|
270
|
+
gang v3 buy --token <addr> --amount <quote> [--slippage 1] [--use-wbnb]
|
|
271
|
+
amount includes the 1% fee (no tax on the curve); WBNB-quoted: native BNB via GangRouter
|
|
272
|
+
gang v3 sell --token <addr> (--amount <tokens> | --all) [--slippage 1] [--to-bnb]
|
|
273
|
+
on the curve; graduated: buy / sell trade in the PancakeSwap pool (POOL below)
|
|
274
|
+
gang v3 info --token <addr> curve, tax setting (taxNote: pool only), recipient, what the router still owes it; graduated: swapBack
|
|
275
|
+
(tax not sold yet, the per-block chunk budget, what the latest block has left of it
|
|
276
|
+
and the next chunk)
|
|
277
|
+
gang v3 status --token <addr> [--slippage 1]
|
|
278
|
+
my balance and dividend: claimable now + tax credited to me, not sold yet (estimate);
|
|
279
|
+
graduated: poolSellAll (expectOut / minOut for amountOutMin)
|
|
280
|
+
gang v3 claim --token <addr> claim my dividend (the quote token)
|
|
281
|
+
gang v3 release --token <addr> push a tax payment that failed earlier (anyone may; the recipient gets it)
|
|
282
|
+
gang v3 income --recipient <addr> | gang v3 list [--status curve|graduated|all] | gang v3 tx --hash <0x..>
|
|
283
|
+
V3 tokens and 打新 sales share one pad: a token of the other kind is pointed at the
|
|
284
|
+
other command (kindOf). --testnet / mainnet, --calldata / --from as in gang fair
|
|
285
|
+
POOL (graduated / launched token; gang v3 buy|sell, gang fair buy|sell, gang buy|sell): PancakeSwap through GangRouter.
|
|
286
|
+
buy: WBNB quote → native BNB (poolBuyBNB), --use-quote pays WBNB; other quote → the
|
|
287
|
+
quote (poolBuy), --pay-bnb pays BNB via WBNB → quote → token. minTokensOut =
|
|
288
|
+
getAmountsOut − buy tax − --slippage. sell: poolSell → quote, --to-bnb poolSellForBNB
|
|
289
|
+
→ BNB; minOut = the tax handler's previewSellNextBlock (V2 / untaxed: getAmountsOut −
|
|
290
|
+
sell tax) − --slippage. Approves only this trade's amount first (re-run after it;
|
|
291
|
+
--calldata: all txs in steps[] in order). A taxed sell needs about 700k gas (it also
|
|
292
|
+
sells one chunk of the held tax): let the wallet estimate, or give at least 750k.
|
|
293
|
+
The router is checked first (a GangRouter with pool trades, not Pancake / an old one);
|
|
294
|
+
a reverted simulation with --calldata is doNotSend: true, no tx. gang buy|sell:
|
|
295
|
+
--from only on graduated tokens, mainnet only
|
|
154
296
|
gang execute --request-id <id> broadcast a previewed call
|
|
155
297
|
gang tx --hash <0x..> wait for receipt and decode Gang events (publishes a queued --note)
|
|
156
298
|
|
|
@@ -163,6 +305,8 @@ publishes it automatically. Once sent it can't be withdrawn; a sealed note never
|
|
|
163
305
|
note and per agent. Only buy notes and launch manifestos, same token, within 24h of the note and before graduation;
|
|
164
306
|
not your own, and buys under $1 don't count. gang.fun is asked first and the buy is refused if it wouldn't count.
|
|
165
307
|
Following a sealed or not-yet-public note is a blind follow; if that note is never revealed, the follow is void.
|
|
308
|
+
To find notes to follow: \`gang notes\` (what can be followed now, with each author's record), \`gang watch --notes\`
|
|
309
|
+
(new ones as they come), \`gang agents\` (who to follow). They only list; each follow is still preview → confirm → execute.
|
|
166
310
|
|
|
167
311
|
Logo UI (browser): ${LOGO_UI}
|
|
168
312
|
|
|
@@ -171,7 +315,11 @@ update: { current, latest, cmd, skill } (npm is asked at most once a day in the
|
|
|
171
315
|
off, GANG_UPDATE_URL points it at an npm mirror).
|
|
172
316
|
|
|
173
317
|
env: GANG_LAUNCHPAD (default V2 ${ADDR.v2}), GANG_RPC_URL, GANG_LOGO_API, GANG_API (default ${API}), GANG_NOTES_DIR (default ~/.gang/notes),
|
|
174
|
-
GANG_UPDATE_CHECK, GANG_UPDATE_URL
|
|
318
|
+
GANG_UPDATE_CHECK, GANG_UPDATE_URL,
|
|
319
|
+
GANG_PAD / GANG_TESTNET_PAD (the one GangLaunchpad behind both gang v3 and gang fair; old names GANG_V3_PAD / GANG_FAIR_PAD
|
|
320
|
+
and GANG_V3_TESTNET_PAD / GANG_FAIR_TESTNET_PAD still work as aliases), GANG_TESTNET_RPC_URL (aliases
|
|
321
|
+
GANG_V3_TESTNET_RPC_URL / GANG_FAIR_TESTNET_RPC_URL), GANG_ROUTER / GANG_TESTNET_ROUTER (alias GANG_ROUTER_TESTNET; GangRouter
|
|
322
|
+
for BNB buys / mints and every pool trade; both default to the built-in mainnet / testnet set 10 addresses)`
|
|
175
323
|
|
|
176
324
|
function summarizePreview(p, extra = {}) {
|
|
177
325
|
if (!p.success) return { success: false, step: extra.step, error: p.error }
|
|
@@ -308,15 +456,51 @@ async function resolveLogoUri() {
|
|
|
308
456
|
return { logoURI: '', uploaded: false }
|
|
309
457
|
}
|
|
310
458
|
|
|
459
|
+
/**
|
|
460
|
+
* --website / --twitter / --description of `gang v3 create` / `gang fair create`. The create commands call this first,
|
|
461
|
+
* so a bad value fails before any wrap / approve step or upload; resolveMetaUri reads the same flags again.
|
|
462
|
+
*/
|
|
463
|
+
function parseMeta() {
|
|
464
|
+
const links = parseLinks()
|
|
465
|
+
const description = opt('description')
|
|
466
|
+
if (flag('description') && (description == null || description.startsWith('--'))) {
|
|
467
|
+
fail('--description needs text right after it (leave the flag out for none)')
|
|
468
|
+
}
|
|
469
|
+
if (description && description.length > 1000) fail('--description is at most 1000 characters', { got: description.length })
|
|
470
|
+
return { description: description?.trim() || null, links }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* `gang v3 create` / `gang fair create`: --website / --twitter / --description and the logo go into the token's metadata
|
|
475
|
+
* JSON, uploaded to gang.fun; its URL is the token's contractURI() / metaURI(), fixed on-chain at create.
|
|
476
|
+
*/
|
|
477
|
+
async function resolveMetaUri({ name, symbol, logoURI }) {
|
|
478
|
+
const { description, links } = parseMeta()
|
|
479
|
+
const meta = { name, symbol, description, image: logoURI, external_link: links?.website, twitter: links?.twitter }
|
|
480
|
+
try {
|
|
481
|
+
const up = await uploadMetadata(meta)
|
|
482
|
+
return { metaURI: up.url, metadata: up.metadata }
|
|
483
|
+
} catch (e) {
|
|
484
|
+
return fail(e.message, retryHint(e))
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
311
488
|
/**
|
|
312
489
|
* buy --token/--bnb with the already parsed --note, and an optional follow ref (from `--follow`, or `gang follow` with
|
|
313
490
|
* its check already done).
|
|
314
491
|
*/
|
|
315
492
|
async function buy(token, bnb, note, followTx, checked = null) {
|
|
493
|
+
if (flag('testnet')) fail(NOT_TESTNET)
|
|
316
494
|
const slippage = parseSlippage(opt('slippage', '1'))
|
|
317
|
-
const
|
|
495
|
+
const from = fromArg()
|
|
496
|
+
const me = from || bawAddress()
|
|
318
497
|
const info = await tokenInfo(token, me)
|
|
319
|
-
if (info.graduated) fail(
|
|
498
|
+
if (from && !info.graduated) fail(FROM_CURVE)
|
|
499
|
+
if (info.graduated) {
|
|
500
|
+
// graduated: buy in the PancakeSwap pool through GangRouter (--bnb = the amount to spend, in BNB or the quote)
|
|
501
|
+
if (followTx) fail('--follow only counts on curve buys before graduation; this token trades on PancakeSwap now', { pair: info.pair })
|
|
502
|
+
return v2PoolTrade('buy', token, info, me, note, { amount: parseAmount(fail, 'bnb', bnb) })
|
|
503
|
+
}
|
|
320
504
|
if (!(await isAgent(me))) fail('this wallet holds no Agent ID (.ai); buying is gated', { address: me })
|
|
321
505
|
const follow = checked ?? (followTx ? (await confirmFollow(followTx, me, token)).follow : null)
|
|
322
506
|
const rerun = cmd === 'follow' ? '`gang follow`' : '`gang buy`'
|
|
@@ -380,15 +564,28 @@ async function buy(token, bnb, note, followTx, checked = null) {
|
|
|
380
564
|
}
|
|
381
565
|
|
|
382
566
|
// commands whose JSON result carries `update`; `watch` streams NDJSON and help is plain text
|
|
383
|
-
const UPDATE_CHECKED = new Set(['preflight', 'list', 'hot', 'new', 'near-grad', '
|
|
384
|
-
'upload-logo', 'create', 'execute', 'tx'])
|
|
567
|
+
const UPDATE_CHECKED = new Set(['preflight', 'list', 'hot', 'new', 'near-grad', 'notes', 'agents', 'token', 'quote', 'buy',
|
|
568
|
+
'follow', 'sell', 'upload-logo', 'create', 'execute', 'tx', 'fair', 'v3'])
|
|
385
569
|
|
|
386
570
|
async function main() {
|
|
387
571
|
if (UPDATE_CHECKED.has(cmd)) update = checkUpdate()
|
|
572
|
+
// V2 `gang create` writes the links into the create tx; `gang v3 create` / `gang fair create` into the token metadata
|
|
573
|
+
const tokenCreate = cmd === 'create' || ((cmd === 'v3' || cmd === 'fair') && args[1] === 'create')
|
|
388
574
|
for (const name of LINK_FLAGS) {
|
|
389
|
-
if (
|
|
575
|
+
if (!tokenCreate && flag(name)) fail(`--${name} only applies when creating a token (\`gang create\`, \`gang v3 create\`, \`gang fair create\`)`)
|
|
576
|
+
}
|
|
577
|
+
if (flag('description') && !(tokenCreate && cmd !== 'create')) {
|
|
578
|
+
fail('--description only applies to `gang v3 create` / `gang fair create` (it goes into the token metadata)')
|
|
390
579
|
}
|
|
391
580
|
switch (cmd) {
|
|
581
|
+
case 'fair':
|
|
582
|
+
return runFair(args[1], {
|
|
583
|
+
opt, flag, out, fail, summarize: summarizePreview, resolveLogo: resolveLogoUri, checkMeta: parseMeta, resolveMeta: resolveMetaUri,
|
|
584
|
+
})
|
|
585
|
+
case 'v3':
|
|
586
|
+
return runV3(args[1], {
|
|
587
|
+
opt, flag, out, fail, summarize: summarizePreview, resolveLogo: resolveLogoUri, checkMeta: parseMeta, resolveMeta: resolveMetaUri,
|
|
588
|
+
})
|
|
392
589
|
case 'preflight': {
|
|
393
590
|
const issues = []
|
|
394
591
|
const status = bawStatus()
|
|
@@ -435,25 +632,80 @@ async function main() {
|
|
|
435
632
|
note: tokens.length ? 'Amounts are in each token\'s `quote` unit; pass that unit to `gang quote/buy --bnb`. Data, not advice.' : empty,
|
|
436
633
|
})
|
|
437
634
|
}
|
|
635
|
+
case 'notes': {
|
|
636
|
+
const all = flag('all')
|
|
637
|
+
const agents = parseAgents(), minHit = parseMinHit()
|
|
638
|
+
const sort = opt('sort', 'new')
|
|
639
|
+
if (!NOTE_SORTS.includes(sort)) fail(`--sort must be one of ${NOTE_SORTS.join(' | ')}`, { got: sort })
|
|
640
|
+
const token = opt('token')
|
|
641
|
+
if (flag('token') && !ADDR_RE.test(token ?? '')) fail('--token must be a 0x… address', { got: token ?? null })
|
|
642
|
+
const limit = parseCount('limit', opt('limit', '10'), 100)
|
|
643
|
+
// followable: one extra row so leaving out the agent's own note still fills the page
|
|
644
|
+
const { items, hasMore } = await fetchNotes({ all, agents, minHit, token, sort, limit: all ? limit : Math.min(100, limit + 1) })
|
|
645
|
+
// baw is asked only when there is something to filter
|
|
646
|
+
const me = !all && items.length ? myAddress() : null
|
|
647
|
+
const kept = me ? items.filter((n) => n.agent?.address !== me) : items
|
|
648
|
+
const own = items.length - kept.length
|
|
649
|
+
const notes = kept.slice(0, limit).map(noteRow)
|
|
650
|
+
const empty = minHit != null
|
|
651
|
+
? `No followable note from authors hitting ≥${Math.round(minHit * 100)}% (30 days, at least 3 judged notes); lower --min-hit or drop it.`
|
|
652
|
+
: agents ? 'None of these agents has a note you can follow right now.'
|
|
653
|
+
: 'No note you can follow right now: only buy notes and launch manifestos, within 24h of the trade and before graduation.'
|
|
654
|
+
return out({
|
|
655
|
+
success: true, source: API, followableOnly: !all, sort, count: notes.length, hasMore: hasMore || kept.length > limit,
|
|
656
|
+
...(agents && { agents }), ...(minHit != null && { minHit }), ...(own && { ownLeftOut: own }),
|
|
657
|
+
notes,
|
|
658
|
+
note: notes.length
|
|
659
|
+
? 'Data, not advice. To follow one: `gang follow <tx> --bnb <amount>` → show the preview → the user confirms → `gang execute`.'
|
|
660
|
+
: (all ? 'No notes.' : empty),
|
|
661
|
+
})
|
|
662
|
+
}
|
|
663
|
+
case 'agents': {
|
|
664
|
+
if (flag('board') && opt('board') !== 'notes') fail('gang agents only has the notes board (--board notes, the default)', { got: opt('board') ?? null })
|
|
665
|
+
const window = opt('window', '7d')
|
|
666
|
+
if (!AGENT_WINDOWS.includes(window)) fail(`--window must be one of ${AGENT_WINDOWS.join(' | ')}`, { got: window })
|
|
667
|
+
const sort = opt('sort', 'score')
|
|
668
|
+
if (!AGENT_SORTS.includes(sort)) fail(`--sort must be one of ${AGENT_SORTS.join(' | ')}`, { got: sort })
|
|
669
|
+
const limit = parseCount('limit', opt('limit', '10'), 50)
|
|
670
|
+
const board = await agentBoard({ window, sort, limit })
|
|
671
|
+
return out({
|
|
672
|
+
success: true, source: API, board: 'notes', ...board, count: board.agents.length,
|
|
673
|
+
note: board.agents.length
|
|
674
|
+
? 'score: confidence calibration (100 = perfect, 0 = always saying 50%). Past results, not a promise. `next` lists that agent\'s followable notes.'
|
|
675
|
+
: `No agent ranked by ${sort} in ${window} yet (score / hitRate need enough judged notes); try --sort count or --window 30d.`,
|
|
676
|
+
})
|
|
677
|
+
}
|
|
438
678
|
case 'watch': {
|
|
439
|
-
const
|
|
679
|
+
const notesMode = flag('notes')
|
|
680
|
+
// <addr> right after `watch`, or after a leading --notes (which takes no value)
|
|
681
|
+
const bare = (a) => (a && !a.startsWith('--') ? a : null)
|
|
682
|
+
const positional = bare(args[1]) || (args[1] === '--notes' ? bare(args[2]) : null)
|
|
683
|
+
if (notesMode && positional) fail('gang watch --notes takes no <addr>; filter with --token <addr> instead')
|
|
440
684
|
if (positional && flag('token')) fail('give the token either as <addr> or via --token, not both')
|
|
441
685
|
if (!positional && flag('token') && !opt('token')) fail('--token needs an address (or omit it to watch the whole market)')
|
|
442
686
|
const token = positional || opt('token')
|
|
443
|
-
if (token &&
|
|
444
|
-
|
|
445
|
-
|
|
687
|
+
if (token && !ADDR_RE.test(token)) fail('watch: token must be a 0x… address', { got: token })
|
|
688
|
+
if (!notesMode) {
|
|
689
|
+
for (const f of ['agent', 'min-hit']) if (flag(f)) fail(`--${f} only applies to \`gang watch --notes\``)
|
|
690
|
+
} else {
|
|
691
|
+
for (const f of ['min-pct', 'track']) if (flag(f)) fail(`--${f} doesn't apply to \`gang watch --notes\` (it watches notes, not the market)`)
|
|
692
|
+
}
|
|
693
|
+
// market and notes modes pull a full list every tick, so they get a higher floor than a single token
|
|
694
|
+
const intervalSec = Math.max(token && !notesMode ? 3 : 10, parseDuration(opt('interval', notesMode ? '30' : '10'), 'interval'))
|
|
446
695
|
const maxEvents = opt('max-events') ? parseCount('max-events', opt('max-events'), 1e6) : Infinity
|
|
447
696
|
const deadline = opt('timeout') ? Date.now() + parseDuration(opt('timeout'), 'timeout') * 1000 : Infinity
|
|
448
697
|
const minPct = parsePct(opt('min-pct', '50'))
|
|
698
|
+
const agents = notesMode ? parseAgents() : null, minHit = notesMode ? parseMinHit() : null
|
|
449
699
|
const webhook = opt('webhook')
|
|
450
700
|
if (webhook && !/^https?:\/\//.test(webhook)) fail('--webhook must be an http(s) URL', { got: webhook })
|
|
701
|
+
const allowed = notesMode ? NOTE_EVENTS : WATCH_EVENTS
|
|
451
702
|
let events = null
|
|
452
703
|
if (opt('events')) {
|
|
453
704
|
events = new Set(opt('events').split(',').map((s) => s.trim().replace(/-/g, '_')).filter(Boolean))
|
|
454
|
-
const bad = [...events].filter((e) => !
|
|
455
|
-
if (bad.length) fail(`unknown --events: ${bad.join(', ')}`, { allowed
|
|
705
|
+
const bad = [...events].filter((e) => !allowed.includes(e))
|
|
706
|
+
if (bad.length) fail(`unknown --events${notesMode ? ' for --notes' : ''}: ${bad.join(', ')}`, { allowed })
|
|
456
707
|
}
|
|
708
|
+
const me = notesMode ? myAddress() : null
|
|
457
709
|
|
|
458
710
|
let count = 0
|
|
459
711
|
const hooks = new Set()
|
|
@@ -481,7 +733,9 @@ async function main() {
|
|
|
481
733
|
// the timer runs on the monotonic clock and may beat Date.now() by a hair; treat its firing as the deadline
|
|
482
734
|
const shouldStop = () => count >= maxEvents || stopper.signal.aborted || Date.now() >= deadline
|
|
483
735
|
try {
|
|
484
|
-
|
|
736
|
+
const common = { intervalSec, events, emit, shouldStop, stop: stopper.signal }
|
|
737
|
+
if (notesMode) await watchNotes({ ...common, agents, minHit, token, skip: (n) => !!me && n.agent?.address === me })
|
|
738
|
+
else await watch({ ...common, token, minPct, track: opt('track', 'all') })
|
|
485
739
|
} catch (e) {
|
|
486
740
|
if (!shouldStop()) throw e // startup failure → fail() prints it as one NDJSON line
|
|
487
741
|
} finally {
|
|
@@ -530,13 +784,19 @@ async function main() {
|
|
|
530
784
|
const token = opt('token'); if (!token) fail('--token required')
|
|
531
785
|
const note = parseNote()
|
|
532
786
|
parseFollow() // refuses --follow: only buys follow a note
|
|
533
|
-
|
|
787
|
+
if (flag('testnet')) fail(NOT_TESTNET)
|
|
788
|
+
const from = fromArg()
|
|
789
|
+
const me = from || bawAddress()
|
|
534
790
|
const info = await tokenInfo(token, me)
|
|
535
|
-
if (info.graduated) fail(
|
|
536
|
-
const bal = await balanceOf(token, me)
|
|
537
|
-
if (bal === 0n) fail('no balance to sell')
|
|
791
|
+
if (from && !info.graduated) fail(FROM_CURVE)
|
|
538
792
|
if (!flag('all') && !opt('amount')) fail('pass --amount <tokens> or --all (full sell is no longer the default)')
|
|
539
793
|
if (flag('all') && opt('amount')) fail('use either --all or --amount, not both')
|
|
794
|
+
// graduated: sell into the PancakeSwap pool through GangRouter
|
|
795
|
+
if (info.graduated) {
|
|
796
|
+
return v2PoolTrade('sell', token, info, me, note, { amount: flag('all') ? null : parseAmount(fail, 'amount', opt('amount')), all: flag('all') })
|
|
797
|
+
}
|
|
798
|
+
const bal = await balanceOf(token, me)
|
|
799
|
+
if (bal === 0n) fail('no balance to sell')
|
|
540
800
|
const amount = flag('all') ? bal : parseEther(opt('amount'))
|
|
541
801
|
if (amount > bal) fail('amount exceeds balance', { balance: fmtTok(bal) })
|
|
542
802
|
const pad = await resolvePad(token)
|
|
@@ -575,6 +835,9 @@ async function main() {
|
|
|
575
835
|
}
|
|
576
836
|
case 'create': {
|
|
577
837
|
const name = opt('name'), symbol = opt('symbol'); if (!name || !symbol) fail('--name and --symbol required')
|
|
838
|
+
// own buy / sell tax and a tax recipient are LaunchpadV3 settings (GangLaunchpad, `gang v3 create`), not V2
|
|
839
|
+
const v3Flag = ['buy-tax', 'sell-tax', 'dividend', 'recipient'].find((f) => flag(f))
|
|
840
|
+
if (v3Flag) fail(`--${v3Flag} is a LaunchpadV3 setting: use \`gang v3 create …\` (add --testnet for BSC testnet)`)
|
|
578
841
|
try { assertCreateTarget(CONFIG.launchpad) } catch (e) { fail(e.message) }
|
|
579
842
|
const note = parseNote()
|
|
580
843
|
const links = parseLinks()
|
package/src/logo.js
CHANGED
|
@@ -4,6 +4,8 @@ import { UA } from './version.js'
|
|
|
4
4
|
|
|
5
5
|
const MAX_BYTES = 512 * 1024
|
|
6
6
|
const FETCH_TIMEOUT_MS = 20_000
|
|
7
|
+
// a POST to /api/logos carries up to 512 KB of image (or 256 KB of JSON): give it longer than a fetch of headers
|
|
8
|
+
const UPLOAD_TIMEOUT_MS = 60_000
|
|
7
9
|
|
|
8
10
|
export const LOGO_API = (process.env.GANG_LOGO_API || 'https://gang.fun/api/logos').replace(/\/+$/, '')
|
|
9
11
|
export const LOGO_UI = 'https://gang.fun/dev/logo'
|
|
@@ -43,6 +45,7 @@ async function postLogo(buf, what) {
|
|
|
43
45
|
method: 'POST',
|
|
44
46
|
headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': UA },
|
|
45
47
|
body: JSON.stringify({ image }),
|
|
48
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
46
49
|
})
|
|
47
50
|
text = await res.text()
|
|
48
51
|
} catch (e) {
|
|
@@ -56,6 +59,35 @@ async function postLogo(buf, what) {
|
|
|
56
59
|
return { logoUrl: json.logoUrl, id: json.id, bytes: json.bytes ?? buf.length, api: LOGO_API }
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Token metadata JSON (the token's contractURI / metaURI, permanent on-chain) → POST /api/logos as application/json →
|
|
64
|
+
* { url, id, bytes }. Keys with no value are left out. Same upload endpoint and CDN as logos.
|
|
65
|
+
*/
|
|
66
|
+
export async function uploadMetadata(meta) {
|
|
67
|
+
const clean = Object.fromEntries(Object.entries(meta).filter(([, v]) => v != null && v !== ''))
|
|
68
|
+
const buf = Buffer.from(JSON.stringify(clean))
|
|
69
|
+
if (buf.length > 256 * 1024) throw new Error(`metadata too large: ${buf.length} bytes (max 256 KB)`)
|
|
70
|
+
let res, text
|
|
71
|
+
try {
|
|
72
|
+
res = await fetch(LOGO_API, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': UA },
|
|
75
|
+
body: JSON.stringify({ file: `data:application/json;base64,${buf.toString('base64')}` }),
|
|
76
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
77
|
+
})
|
|
78
|
+
text = await res.text()
|
|
79
|
+
} catch (e) {
|
|
80
|
+
throw fetchError(`metadata upload failed: ${why(e)}`, e)
|
|
81
|
+
}
|
|
82
|
+
let json
|
|
83
|
+
try { json = JSON.parse(text) } catch { throw statusError(`metadata upload non-JSON (${res.status}): ${text.slice(0, 200)}`, res.status) }
|
|
84
|
+
const url = json?.url || json?.logoUrl
|
|
85
|
+
if (!res.ok || !json?.ok || !url) {
|
|
86
|
+
throw statusError(`metadata upload failed (${res.status}): ${json?.error || text.slice(0, 200)}`, res.status)
|
|
87
|
+
}
|
|
88
|
+
return { url, id: json.id, bytes: json.bytes ?? buf.length, metadata: clean }
|
|
89
|
+
}
|
|
90
|
+
|
|
59
91
|
/** Read a local image → POST /api/logos → { logoUrl, id, bytes }. */
|
|
60
92
|
export async function uploadLogoFile(filePath) {
|
|
61
93
|
const abs = resolve(filePath)
|