@gangfun/cli 0.8.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 +37 -3
- package/README.zh-CN.md +34 -3
- package/package.json +2 -2
- package/src/abi.js +17 -0
- package/src/chain.js +14 -5
- package/src/fair.js +506 -0
- package/src/gangpad.js +108 -0
- package/src/index.js +183 -10
- 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,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
|
|
495
|
+
const from = fromArg()
|
|
496
|
+
const me = from || bawAddress()
|
|
351
497
|
const info = await tokenInfo(token, me)
|
|
352
|
-
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
|
+
}
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|