@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/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 me = bawAddress()
495
+ const from = fromArg()
496
+ const me = from || bawAddress()
318
497
  const info = await tokenInfo(token, me)
319
- if (info.graduated) fail('token already graduated; trade it on PancakeSwap', { pair: info.pair })
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', 'token', 'quote', 'buy', 'follow', 'sell',
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 (cmd !== 'create' && flag(name)) fail(`--${name} only applies to \`gang create\` (the link is written into the create tx)`)
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 positional = args[1] && !args[1].startsWith('--') ? args[1] : null
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 && !/^0x[0-9a-fA-F]{40}$/.test(token)) fail('watch: token must be a 0x… address', { got: token })
444
- // market mode pulls the full token list every tick, so it gets a higher floor than a single token
445
- const intervalSec = Math.max(token ? 3 : 10, parseDuration(opt('interval', '10'), 'interval'))
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) => !WATCH_EVENTS.includes(e))
455
- if (bad.length) fail(`unknown --events: ${bad.join(', ')}`, { allowed: WATCH_EVENTS })
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
- await watch({ token, intervalSec, minPct, track: opt('track', 'all'), events, emit, shouldStop, stop: stopper.signal })
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
- const me = bawAddress()
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('token already graduated; sell on PancakeSwap', { pair: info.pair })
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)