@gangfun/cli 0.8.0 → 0.9.1

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,7 +4,7 @@ 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,
@@ -15,6 +15,12 @@ import { NOTE_SORTS, AGENT_WINDOWS, AGENT_SORTS, NOTE_EVENTS, noteRow, fetchNote
15
15
  import { NOTE_TX, MIN_FOLLOW_USD, REASONS, noteLink, checkFollow, amountUsd, localTime } from './follow.js'
16
16
  import { rememberPreview, previewFor } from './previews.js'
17
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'
18
24
 
19
25
  const args = process.argv.slice(2)
20
26
  const cmd = args[0]
@@ -73,6 +79,41 @@ const myAddress = () => {
73
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)
74
80
  const GAS_RESERVE = parseEther('0.002')
75
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
+
76
117
  // --note is validated before any RPC / preview, so a bad note never reaches calldata
77
118
  const parseNote = () => {
78
119
  if (!flag('note')) {
@@ -171,9 +212,12 @@ const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet
171
212
  gang quote --token <addr> --bnb <n> Soft: BNB quote; V2/U/S: quote-token amount (same flag)
172
213
  gang buy --token <addr> --bnb <n> [--slippage 1] [--follow <note tx>] [--note "<why>" [--conf 0-100] [--reveal-after 1-72h]]
173
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)
174
217
  gang follow <note tx> --bnb <n> [--slippage 1] [--note …]
175
218
  buy the token that note is about, marked on-chain as following it (= buy --follow)
176
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])
177
221
  gang upload-logo (--file <path> | --url <image URL>)
178
222
  copy an image to https://gang.fun/api/logos → logoUrl
179
223
  gang create --name <n> --symbol <s> [--logo <file> | --logo-uri URL] [--website URL] [--twitter @handle|x.com link]
@@ -182,6 +226,73 @@ const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet
182
226
  --website / --twitter (optional) go into the create tx, shown on gang.fun; permanent
183
227
  --logo-uri images are copied to gang.fun first (logoURI is permanent on-chain;
184
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
185
296
  gang execute --request-id <id> broadcast a previewed call
186
297
  gang tx --hash <0x..> wait for receipt and decode Gang events (publishes a queued --note)
187
298
 
@@ -204,7 +315,11 @@ update: { current, latest, cmd, skill } (npm is asked at most once a day in the
204
315
  off, GANG_UPDATE_URL points it at an npm mirror).
205
316
 
206
317
  env: GANG_LAUNCHPAD (default V2 ${ADDR.v2}), GANG_RPC_URL, GANG_LOGO_API, GANG_API (default ${API}), GANG_NOTES_DIR (default ~/.gang/notes),
207
- 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)`
208
323
 
209
324
  function summarizePreview(p, extra = {}) {
210
325
  if (!p.success) return { success: false, step: extra.step, error: p.error }
@@ -341,15 +456,51 @@ async function resolveLogoUri() {
341
456
  return { logoURI: '', uploaded: false }
342
457
  }
343
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
+
344
488
  /**
345
489
  * buy --token/--bnb with the already parsed --note, and an optional follow ref (from `--follow`, or `gang follow` with
346
490
  * its check already done).
347
491
  */
348
492
  async function buy(token, bnb, note, followTx, checked = null) {
493
+ if (flag('testnet')) fail(NOT_TESTNET)
349
494
  const slippage = parseSlippage(opt('slippage', '1'))
350
- const me = bawAddress()
495
+ const from = fromArg()
496
+ const me = from || bawAddress()
351
497
  const info = await tokenInfo(token, me)
352
- 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
+ }
353
504
  if (!(await isAgent(me))) fail('this wallet holds no Agent ID (.ai); buying is gated', { address: me })
354
505
  const follow = checked ?? (followTx ? (await confirmFollow(followTx, me, token)).follow : null)
355
506
  const rerun = cmd === 'follow' ? '`gang follow`' : '`gang buy`'
@@ -414,14 +565,27 @@ async function buy(token, bnb, note, followTx, checked = null) {
414
565
 
415
566
  // commands whose JSON result carries `update`; `watch` streams NDJSON and help is plain text
416
567
  const UPDATE_CHECKED = new Set(['preflight', 'list', 'hot', 'new', 'near-grad', 'notes', 'agents', 'token', 'quote', 'buy',
417
- 'follow', 'sell', 'upload-logo', 'create', 'execute', 'tx'])
568
+ 'follow', 'sell', 'upload-logo', 'create', 'execute', 'tx', 'fair', 'v3'])
418
569
 
419
570
  async function main() {
420
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')
421
574
  for (const name of LINK_FLAGS) {
422
- 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)')
423
579
  }
424
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
+ })
425
589
  case 'preflight': {
426
590
  const issues = []
427
591
  const status = bawStatus()
@@ -620,13 +784,19 @@ async function main() {
620
784
  const token = opt('token'); if (!token) fail('--token required')
621
785
  const note = parseNote()
622
786
  parseFollow() // refuses --follow: only buys follow a note
623
- const me = bawAddress()
787
+ if (flag('testnet')) fail(NOT_TESTNET)
788
+ const from = fromArg()
789
+ const me = from || bawAddress()
624
790
  const info = await tokenInfo(token, me)
625
- if (info.graduated) fail('token already graduated; sell on PancakeSwap', { pair: info.pair })
626
- const bal = await balanceOf(token, me)
627
- if (bal === 0n) fail('no balance to sell')
791
+ if (from && !info.graduated) fail(FROM_CURVE)
628
792
  if (!flag('all') && !opt('amount')) fail('pass --amount <tokens> or --all (full sell is no longer the default)')
629
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')
630
800
  const amount = flag('all') ? bal : parseEther(opt('amount'))
631
801
  if (amount > bal) fail('amount exceeds balance', { balance: fmtTok(bal) })
632
802
  const pad = await resolvePad(token)
@@ -665,6 +835,9 @@ async function main() {
665
835
  }
666
836
  case 'create': {
667
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)`)
668
841
  try { assertCreateTarget(CONFIG.launchpad) } catch (e) { fail(e.message) }
669
842
  const note = parseNote()
670
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)
package/src/pool.js ADDED
@@ -0,0 +1,312 @@
1
+ import { parseAbi, encodeFunctionData, formatUnits, parseUnits, zeroAddress } from 'viem'
2
+ import { decimalsOf, isMissingFunction, poolSellQuote, ROUTER_ABI, routerDeadline, step, units, GAS_RESERVE } from './steps.js'
3
+
4
+ /**
5
+ * PancakeSwap (V2 pool) buys and sells of a graduated / launched token through GangRouter — V3 and 打新 tokens
6
+ * (`gang v3 buy|sell`, `gang fair buy|sell`) and V2 / Soft / U / S tokens (`gang buy|sell`), all the same way:
7
+ * poolBuyBNB(token, quote, minTokensOut, deadline) payable path [WBNB, token] or [WBNB, quote, token]
8
+ * poolBuy(token, quote, quoteIn, minTokensOut, deadline) path [quote, token]; approve quote → router first
9
+ * poolSell(token, quote, amountIn, minQuoteOut, deadline) path [token, quote]; approve token → router first
10
+ * poolSellForBNB(token, quote, amountIn, minBnbOut, deadline) path [token, quote(, WBNB)], paid out as BNB
11
+ * The token lands in / the proceeds go to the caller. Approvals are for this trade's amount only.
12
+ * Minimums: a buy = Pancake getAmountsOut less the token's buy tax, less --slippage; a sell = the tax handler's
13
+ * previewSellNextBlock (the sell tax and the chunk of held tax it sells first included, as of the next block), or
14
+ * getAmountsOut less the sell tax when there is no handler quote (V2 tokens, untaxed V3), less --slippage.
15
+ */
16
+
17
+ // Pancake V2 routers: a GangRouter address must never be one of these (they would take an approve and swap for anyone).
18
+ // Quotes use the GangRouter's own pancakeRouter(), the router its pool* functions swap through.
19
+ const DEX_ROUTERS = ['0x10ED43C718714eb63d5aA57B78B54704E256024E', '0xD99D1c33F9fC3444f8101754aBC46c52416550D1']
20
+ const DEX_ABI = parseAbi(['function getAmountsOut(uint256 amountIn, address[] path) view returns (uint256[] amounts)'])
21
+ const HANDLER_DEX_ABI = parseAbi(['function dexRouter() view returns (address)'])
22
+ const ERC20_ABI = parseAbi([
23
+ 'function balanceOf(address) view returns (uint256)',
24
+ 'function allowance(address, address) view returns (uint256)',
25
+ 'function approve(address, uint256) returns (bool)',
26
+ 'function symbol() view returns (string)',
27
+ ])
28
+ const WBNB_ABI = parseAbi(['function deposit() payable'])
29
+
30
+ /** Gas note on a taxed gang token's pool sell (it sells a chunk of the held tax right before it). */
31
+ export const POOL_SELL_GAS_NOTE = 'this sell needs about 700k gas (it also sells one chunk of the held tax first): let the wallet estimate gas, or give at least 750k; too little gas reverts with SwapBackOutOfGas'
32
+
33
+ const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase()
34
+ const minOf = (x, slippagePct) => (x * BigInt(Math.round((100 - slippagePct) * 100))) / 10_000n
35
+ const lessBps = (x, bps) => x - (x * BigInt(bps)) / 10_000n
36
+ const symbolOf = async (ctx, t) => { try { return await ctx.read(t, ERC20_ABI, 'symbol') } catch { return null } }
37
+ const short = (e) => e?.shortMessage || String(e?.message || e).split('\n')[0]
38
+
39
+ /** A contract call that reached the chain and reverted (or got no data back), as opposed to an RPC / network failure. */
40
+ const isRevert = (e) => !!e?.walk?.((x) => ['ContractFunctionRevertedError', 'ContractFunctionZeroDataError', 'ExecutionRevertedError'].includes(x?.name) || x?.code === 3)
41
+
42
+ /** A failure the command prints as is (a thrown PoolError ends poolTrade with { success: false, …fields }). */
43
+ class PoolError extends Error {
44
+ constructor(message, fields = {}) { super(message); this.fields = fields }
45
+ }
46
+ const rpcError = (what, e) => new PoolError(`RPC error while ${what}: ${short(e)}`, { hint: 're-run; set GANG_RPC_URL / GANG_TESTNET_RPC_URL if the RPC keeps failing' })
47
+
48
+ /** Last hop of Pancake getAmountsOut; a revert = no usable pool on the path, anything else = the RPC. */
49
+ async function amountOut(ctx, dex, amountIn, path) {
50
+ try {
51
+ const amounts = await ctx.read(dex, DEX_ABI, 'getAmountsOut', [amountIn, path])
52
+ return amounts[amounts.length - 1]
53
+ } catch (e) {
54
+ if (!isRevert(e)) throw rpcError('quoting on PancakeSwap', e)
55
+ throw new PoolError(`PancakeSwap has no usable pool on the path ${path.join(' → ')}`, { path, detail: short(e), noPool: true })
56
+ }
57
+ }
58
+
59
+ const ROUTER_VIEW_ABI = parseAbi([
60
+ 'function pad() view returns (address)', 'function wbnb() view returns (address)', 'function pancakeRouter() view returns (address)',
61
+ ])
62
+
63
+ /**
64
+ * ctx.router must be a GangRouter with the pool functions, checked before any step is built (an approve to anything
65
+ * else would hand it the tokens): a contract; not a known DEX router nor the tax handler's dexRouter(); pad() (= ctx.pad
66
+ * for V3 / 打新) and wbnb() (= this chain's) readable; and pancakeRouter() — only the pool version has it — which is
67
+ * also where the quotes come from. Returns the Pancake router; throws a PoolError otherwise.
68
+ */
69
+ async function gangRouter(ctx, handler, wantPad) {
70
+ const r = ctx.router
71
+ const notGang = (why) => new PoolError(`the router address ${r} is not a GangRouter (${why})`, {
72
+ router: r, hint: `set ${Number(ctx.chainId) === 56 ? 'GANG_ROUTER' : 'GANG_TESTNET_ROUTER'} to the GangRouter proxy`,
73
+ })
74
+ let code
75
+ try { code = await ctx.client.getCode({ address: r }) } catch (e) { throw rpcError('reading the router', e) }
76
+ if (!code || code === '0x') throw notGang(`no contract at it on chain ${ctx.chainId}`)
77
+ if (DEX_ROUTERS.some((d) => same(d, r))) throw notGang('it is the PancakeSwap V2 router')
78
+ if (handler) {
79
+ const dex = await ctx.read(handler, HANDLER_DEX_ABI, 'dexRouter').catch(() => null)
80
+ if (dex && same(dex, r)) throw notGang('it is the tax handler\'s dexRouter')
81
+ }
82
+ const view = async (fn) => {
83
+ try { return await ctx.read(r, ROUTER_VIEW_ABI, fn) } catch (e) {
84
+ if (!isRevert(e)) throw rpcError('reading the router', e)
85
+ return null
86
+ }
87
+ }
88
+ const [pad, wbnb, pancake] = [await view('pad'), await view('wbnb'), await view('pancakeRouter')]
89
+ if (!pad || !wbnb) throw notGang('it has no pad() / wbnb()')
90
+ if (!same(wbnb, ctx.wbnb)) throw notGang(`its wbnb() is ${wbnb}, not this chain's WBNB`)
91
+ if (wantPad && !same(pad, wantPad)) throw notGang(`its pad() is ${pad}, not this CLI's pad ${wantPad}`)
92
+ if (!pancake || pancake === zeroAddress) {
93
+ throw new PoolError(`the GangRouter at ${r} is an older version without PancakeSwap trades (no pancakeRouter() / pool functions)`, {
94
+ router: r, hint: 'it cannot trade in the pool and cannot be upgraded: use a CLI / GANG_ROUTER that points at the new GangRouter proxy, or trade on PancakeSwap directly',
95
+ })
96
+ }
97
+ return pancake
98
+ }
99
+
100
+ /**
101
+ * The pool function itself answers: called with a zero amount, the pool version reverts ZeroAmount() before touching
102
+ * anything. No answer (empty revert) = no such function on this router: refused before any approve.
103
+ */
104
+ async function probePool(ctx, me, functionName, args, revertOf) {
105
+ const data = encodeFunctionData({ abi: ROUTER_ABI, functionName, args })
106
+ try {
107
+ await ctx.client.call({ account: me, to: ctx.router, data, value: 0n })
108
+ } catch (e) {
109
+ if (!isRevert(e)) throw rpcError('checking the router', e)
110
+ if (revertOf(e)?.error === 'ZeroAmount') return
111
+ if (isMissingFunction(e)) {
112
+ throw new PoolError(`the router at ${ctx.router} has no ${functionName}: not the GangRouter version with pool trades`, {
113
+ router: ctx.router, hint: 'use a CLI / GANG_ROUTER that points at the new GangRouter proxy, or trade on PancakeSwap directly',
114
+ })
115
+ }
116
+ throw new PoolError(`the router at ${ctx.router} answered ${functionName} unexpectedly`, { router: ctx.router, ...(revertOf(e) || { detail: short(e) }) })
117
+ }
118
+ throw new PoolError(`the router at ${ctx.router} accepted a zero-amount ${functionName}: not a GangRouter`, { router: ctx.router })
119
+ }
120
+
121
+ /**
122
+ * What the wallet still has to do before `need` of `token` can be pulled by the router: a WBNB wrap when it is WBNB and
123
+ * the wallet is short, then an approve for exactly `need`. [] when ready; { error } when it cannot pay.
124
+ */
125
+ async function fundingOf(ctx, { me, token, need, spender, sym, approveStep }) {
126
+ const bal = await ctx.read(token, ERC20_ABI, 'balanceOf', [me])
127
+ const steps = []
128
+ if (bal < need) {
129
+ if (!same(token, ctx.wbnb)) {
130
+ return { error: { error: `not enough ${sym || 'tokens'}`, have: await units(ctx, token, bal), need: await units(ctx, token, need) } }
131
+ }
132
+ const missing = need - bal
133
+ const native = await ctx.client.getBalance({ address: me })
134
+ if (native < missing + GAS_RESERVE) {
135
+ return { error: { error: 'not enough BNB + WBNB (native BNB must also cover gas)', haveBnb: formatUnits(native, 18), needBnb: formatUnits(missing, 18), reserveForGas: formatUnits(GAS_RESERVE, 18) } }
136
+ }
137
+ steps.push({
138
+ tx: { from: me, to: token, data: encodeFunctionData({ abi: WBNB_ABI, functionName: 'deposit' }), value: missing },
139
+ meta: { step: 'wrap-bnb', wrap: formatUnits(missing, 18) },
140
+ })
141
+ }
142
+ if ((await ctx.read(token, ERC20_ABI, 'allowance', [me, spender])) < need) {
143
+ steps.push({
144
+ tx: { from: me, to: token, data: encodeFunctionData({ abi: ERC20_ABI, functionName: 'approve', args: [spender, need] }) },
145
+ meta: { step: approveStep, approve: await units(ctx, token, need), token, spender, note: 'only this trade\'s amount is approved' },
146
+ })
147
+ }
148
+ return { steps }
149
+ }
150
+
151
+ /**
152
+ * Pay a pool buy in native BNB? WBNB-quoted: yes unless --use-quote (or the curve's --use-wbnb); another quote: only
153
+ * with --pay-bnb (path WBNB → quote → token).
154
+ */
155
+ export const poolPayBnb = (ctx, quote, flag) =>
156
+ (!quote || quote === zeroAddress || same(quote, ctx.wbnb) ? !flag('use-quote') && !flag('use-wbnb') : flag('pay-bnb'))
157
+
158
+ const rawTx = (ctx, { from, to, data, value = 0n }) => ({ chainId: Number(ctx.chainId), from, to, data, value: value.toString() })
159
+
160
+ /**
161
+ * Build a PancakeSwap trade through GangRouter. Returns the object the command prints.
162
+ * p: { side: 'buy'|'sell', token, quote, me, amount (decimal string; sell: null with all), all, slippage,
163
+ * buyTaxBps, sellTaxBps, handler (gang tax handler or null), swapBackGas (taxed gang token: the 700k note),
164
+ * payBnb (buy), toBnb (sell), base (fields every step carries) }
165
+ * io: { rawOnly, summarize, revertOf } — revertOf(e) → { error, hint } | null
166
+ */
167
+ export async function poolTrade(ctx, p, io) {
168
+ try {
169
+ return await build(ctx, p, io)
170
+ } catch (e) {
171
+ if (!(e instanceof PoolError)) throw e
172
+ return { success: false, ...(p.base || {}), step: `pool-${p.side}`, error: e.message, ...e.fields }
173
+ }
174
+ }
175
+
176
+ async function build(ctx, p, { rawOnly, summarize, revertOf }) {
177
+ const { side, token, me, slippage, base = {} } = p
178
+ const quote = !p.quote || p.quote === zeroAddress ? ctx.wbnb : p.quote
179
+ const quoteWbnb = same(quote, ctx.wbnb)
180
+ const failOut = (o) => ({ success: false, ...base, ...o })
181
+ if (!ctx.router) {
182
+ return failOut({ step: `pool-${side}`, error: 'no GangRouter address for this network in this CLI version', hint: `set ${Number(ctx.chainId) === 56 ? 'GANG_ROUTER' : 'GANG_TESTNET_ROUTER'} to the GangRouter proxy, or trade on PancakeSwap directly` })
183
+ }
184
+ const dex = await gangRouter(ctx, p.handler, ctx.pad || null)
185
+ const quoteSymbol = (await symbolOf(ctx, quote)) || quote
186
+ const deadline = routerDeadline()
187
+ const taxPct = (bps) => (bps || 0) / 100
188
+ let call, probe, value = 0n, pull = null, fields
189
+ try {
190
+ if (side === 'buy') {
191
+ const payBnb = !!p.payBnb
192
+ const amountIn = parseUnits(p.amount, payBnb ? 18 : await decimalsOf(ctx, quote))
193
+ if (amountIn === 0n) throw new PoolError(`--amount ${p.amount} rounds to 0 in ${payBnb ? 'BNB' : quoteSymbol}`)
194
+ const path = payBnb ? (quoteWbnb ? [ctx.wbnb, token] : [ctx.wbnb, quote, token]) : [quote, token]
195
+ const gross = await amountOut(ctx, dex, amountIn, path)
196
+ const expect = lessBps(gross, p.buyTaxBps || 0)
197
+ const minOut = minOf(expect, slippage)
198
+ if (payBnb) {
199
+ const native = await ctx.client.getBalance({ address: me }).catch((e) => { throw rpcError('reading the BNB balance', e) })
200
+ if (native < amountIn + GAS_RESERVE) {
201
+ return failOut({
202
+ step: 'pool-buy', error: 'not enough BNB for this buy (native BNB must also cover gas)',
203
+ haveBnb: formatUnits(native, 18), needBnb: p.amount, reserveForGas: formatUnits(GAS_RESERVE, 18),
204
+ ...(quoteWbnb && { hint: 'add --use-quote to pay from WBNB instead' }),
205
+ })
206
+ }
207
+ call = { functionName: 'poolBuyBNB', args: [token, quote, minOut, deadline] }
208
+ probe = [token, quote, 0n, deadline]
209
+ value = amountIn
210
+ } else {
211
+ call = { functionName: 'poolBuy', args: [token, quote, amountIn, minOut, deadline] }
212
+ probe = [token, quote, 0n, 0n, deadline]
213
+ pull = { token: quote, need: amountIn, sym: quoteSymbol, approveStep: 'approve-quote' }
214
+ }
215
+ fields = {
216
+ step: 'pool-buy', venue: 'pancakeswap', via: 'router', router: ctx.router, function: call.functionName,
217
+ spend: p.amount, payWith: payBnb ? 'BNB' : quoteSymbol, quote: quoteSymbol, path,
218
+ expectTokensBeforeTax: formatUnits(gross, 18), buyTaxPct: taxPct(p.buyTaxBps),
219
+ expectTokens: formatUnits(expect, 18), minTokensOut: formatUnits(minOut, 18), slippagePct: slippage,
220
+ deadline: Number(deadline), quotedWith: 'getAmountsOut',
221
+ note: `the tokens go straight to ${me}; minTokensOut = getAmountsOut less the ${taxPct(p.buyTaxBps)}% buy tax, less --slippage (the buy reverts below it)`,
222
+ }
223
+ } else {
224
+ const bal = await ctx.read(token, ERC20_ABI, 'balanceOf', [me]).catch((e) => { throw rpcError('reading the token balance', e) })
225
+ if (bal === 0n) return failOut({ step: 'pool-sell', error: 'no balance to sell', wallet: me })
226
+ const amount = p.all ? bal : parseUnits(p.amount, 18)
227
+ if (amount === 0n) throw new PoolError(`--amount ${p.amount} rounds to 0 tokens`)
228
+ if (amount > bal) return failOut({ step: 'pool-sell', error: 'amount exceeds balance', balance: formatUnits(bal, 18) })
229
+ const taxed = (p.sellTaxBps || 0) > 0
230
+ let quoteOut, quotedWith
231
+ if (taxed && p.handler) {
232
+ // a taxed gang token: only the handler's quote counts the chunk of held tax sold right before the sell
233
+ const q = await poolSellQuote(ctx, p.handler, token, amount)
234
+ if (!q) throw new PoolError('the tax handler gave no sell quote (RPC error or revert): no minimum is guessed for a taxed sell', { handler: p.handler, hint: 're-run' })
235
+ if (q.out === 0n) throw new PoolError('the tax handler quotes 0 for this sell (the token has no pool tax pair set): no minimum to sell against', { handler: p.handler })
236
+ quoteOut = q.out; quotedWith = q.quotedWith
237
+ } else {
238
+ // untaxed, or a V2 token (its tax handler sells nothing during the sell): getAmountsOut of what reaches the pair
239
+ quoteOut = await amountOut(ctx, dex, lessBps(amount, p.sellTaxBps || 0), [token, quote])
240
+ quotedWith = 'getAmountsOut'
241
+ }
242
+ const toBnb = !!p.toBnb
243
+ const path = toBnb && !quoteWbnb ? [token, quote, ctx.wbnb] : [token, quote]
244
+ const out = toBnb && !quoteWbnb ? await amountOut(ctx, dex, quoteOut, [quote, ctx.wbnb]) : quoteOut
245
+ const minOut = minOf(out, slippage)
246
+ call = { functionName: toBnb ? 'poolSellForBNB' : 'poolSell', args: [token, quote, amount, minOut, deadline] }
247
+ probe = [token, quote, 0n, 0n, deadline]
248
+ pull = { token, need: amount, sym: null, approveStep: 'approve' }
249
+ const outDec = toBnb ? 18 : await decimalsOf(ctx, quote)
250
+ fields = {
251
+ step: 'pool-sell', venue: 'pancakeswap', via: 'router', router: ctx.router, function: call.functionName,
252
+ amount: formatUnits(amount, 18), receive: toBnb ? 'BNB' : quoteSymbol, quote: quoteSymbol, path,
253
+ sellTaxPct: taxPct(p.sellTaxBps), expectOut: formatUnits(out, outDec), minOut: formatUnits(minOut, outDec), slippagePct: slippage,
254
+ deadline: Number(deadline), quotedWith,
255
+ ...(toBnb && !quoteWbnb && { expectQuoteBeforeBnb: await units(ctx, quote, quoteOut) }),
256
+ gas: p.swapBackGas ? POOL_SELL_GAS_NOTE : 'let the wallet estimate gas',
257
+ note: quotedWith === 'getAmountsOut'
258
+ ? `minOut = getAmountsOut of the amount less the ${taxPct(p.sellTaxBps)}% sell tax${toBnb && !quoteWbnb ? ' (then quote → WBNB)' : ''}, less --slippage (the sell reverts below it)`
259
+ : `minOut = the tax handler's ${quotedWith} (sell tax and the chunk of held tax sold first included, as of the next block)${toBnb && !quoteWbnb ? ', then getAmountsOut quote → WBNB' : ''}, less --slippage (the sell reverts below it)`,
260
+ }
261
+ }
262
+ } catch (e) {
263
+ if (e instanceof PoolError && e.fields.noPool) {
264
+ // a BNB leg through a non-WBNB quote needs a WBNB/quote pool on Pancake as well
265
+ const bnbLeg = !quoteWbnb && (side === 'buy' ? p.payBnb : p.toBnb)
266
+ const { noPool: _n, ...f } = e.fields
267
+ return failOut({
268
+ step: `pool-${side}`, error: e.message, ...f,
269
+ ...(bnbLeg && { hint: `no WBNB/${quoteSymbol} pool to route through: ${side === 'buy' ? 'drop --pay-bnb to pay with the quote' : 'drop --to-bnb to receive the quote'}` }),
270
+ })
271
+ }
272
+ throw e
273
+ }
274
+
275
+ // the router has this pool function (an old router is refused here, before any approve)
276
+ await probePool(ctx, me, call.functionName, probe, revertOf)
277
+ const swap = { from: me, to: ctx.router, data: encodeFunctionData({ abi: ROUTER_ABI, ...call }), value }
278
+ const pre = pull ? await fundingOf(ctx, { me, ...pull, spender: ctx.router }) : { steps: [] }
279
+ if (pre.error) return failOut({ step: fields.step, ...pre.error })
280
+ const extra = { chainId: Number(ctx.chainId), token, ...base }
281
+
282
+ if (pre.steps.length) {
283
+ if (rawOnly) {
284
+ // raw txs: every step in order (the swap is not simulated: it needs the steps before it on-chain)
285
+ const steps = [...pre.steps.map((s) => ({ ...s.meta, tx: rawTx(ctx, s.tx) })), { step: fields.step, function: fields.function, tx: rawTx(ctx, swap) }]
286
+ return {
287
+ success: true, ...extra, ...fields, step: steps[0].step, steps, tx: steps[0].tx,
288
+ note: `send the ${steps.length} txs in steps[] in order (tx = the first); the ${fields.step} is not simulated until the ${steps.slice(0, -1).map((s) => s.step).join(' / ')} is mined. ${fields.note}`,
289
+ }
290
+ }
291
+ const first = pre.steps[0]
292
+ return step(ctx, first.tx, {
293
+ ...extra, ...first.meta, next: fields.step,
294
+ then: { function: fields.function, ...(side === 'buy' ? { spend: fields.spend, minTokensOut: fields.minTokensOut } : { amount: fields.amount, minOut: fields.minOut }) },
295
+ note: `${first.meta.note ? `${first.meta.note}. ` : ''}after executing, re-run the same command for the ${pre.steps.length > 1 ? 'next step' : fields.step}`,
296
+ }, false, summarize)
297
+ }
298
+
299
+ // eth_call the swap: a revert names the reason (router / pool / token) and builds no preview
300
+ try {
301
+ await ctx.client.call({ account: me, to: swap.to, data: swap.data, value: swap.value })
302
+ } catch (e) {
303
+ if (!isRevert(e)) throw rpcError('simulating the trade', e)
304
+ const r = revertOf(e)
305
+ return failOut({
306
+ ...extra, ...fields, reverted: true, ...(r || { error: short(e) }),
307
+ // no `tx` key: nothing here is meant to be signed
308
+ ...(rawOnly && { doNotSend: true, unsentTxForInspection: rawTx(ctx, swap), txNote: 'DO NOT SEND: the simulation reverted; shown for inspection only' }),
309
+ })
310
+ }
311
+ return step(ctx, swap, { ...extra, ...fields }, rawOnly, summarize)
312
+ }