@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/src/v3.js ADDED
@@ -0,0 +1,504 @@
1
+ import { createPublicClient, http, parseAbi, encodeFunctionData, decodeEventLog, formatUnits, parseUnits, isAddress, zeroAddress } from 'viem'
2
+ import { decimalsOf, defaultRefPool, dividendOf, VAULT_ABI, fundingStep as fund, parseAmount, parseNum, poolSellQuote, POOL_SELL_HINT, revertOf as revertIn, ROUTER_ABI as GANG_ROUTER_ABI, ROUTER_HINTS, routerDeadline, step, swapBackState, units, viaRouter, GAS_RESERVE } from './steps.js'
3
+ import { API } from './discover.js'
4
+ import { UA } from './version.js'
5
+ import { walletFor } from './fair.js'
6
+ import { MERGED_ERRORS_ABI, MERGED_HINTS, NOT_ON_MAINNET, notThisKind, padNets } from './gangpad.js'
7
+ import { poolPayBnb, poolTrade } from './pool.js'
8
+
9
+ /**
10
+ * `gang v3 …` — LaunchpadV3 (tax recipient): a token with its own buy / sell tax (1–5% each), a dividend share and a
11
+ * recipient (the creator itself, or a whitelisted partner such as its treasury) paid in the quote token. The tax is charged only in the PancakeSwap
12
+ * pool after graduation (the platform keeps 0.5% of that volume); curve trades pay only the 1% fee, no tax.
13
+ * Contracts: gang-launchpad GangLaunchpad (merged pad: V3 curves and 打新 sales on one address; see gangpad.js).
14
+ * Mainnet needs a pad address (GANG_PAD or the one shipped in gangpad.js); without it mainnet answers "no mainnet address".
15
+ */
16
+ export const V3_NETS = padNets('v3')
17
+
18
+ const TAX = '(uint16 buyTaxBps, uint16 sellTaxBps, uint16 dividendBps, address recipient)'
19
+ const PAD_ABI = parseAbi([
20
+ `function create(string name, string symbol, string logoURI, string metaURI, address quote, address refPool, ${TAX} tax) returns (address)`,
21
+ 'function buy(address token, uint256 quoteIn, uint256 minTokensOut) returns (uint256)',
22
+ 'function sell(address token, uint256 tokensIn, uint256 minQuoteOut) returns (uint256)',
23
+ `function checkTaxConfig(${TAX} tax) view returns (bool ok, string reason)`,
24
+ `function checkTaxConfigFor(address creator, ${TAX} tax) view returns (bool ok, string reason)`,
25
+ 'function curves(address token) view returns (address creator, address quote, uint128 realQuote, uint128 sold, uint128 gradAmt, uint128 virtualQuote, bool graduated, uint16 buyTaxBps, uint16 sellTaxBps, uint16 dividendBps, address pair, address refPair)',
26
+ 'function taxConfigOf(address token) view returns (uint16 buyTaxBps, uint16 sellTaxBps, uint16 dividendBps, address recipient)',
27
+ 'function isAgent(address account) view returns (bool)',
28
+ 'function walletRoom(address token, address buyer) view returns (uint256)',
29
+ 'function graduationRemaining(address token) view returns (uint256)',
30
+ 'function graduationAmountOf(address quote, address refPool) view returns (uint256)',
31
+ 'function taxHandler() view returns (address)',
32
+ 'function dividendVault() view returns (address)',
33
+ 'function router() view returns (address)',
34
+ 'function recipientRegistry() view returns (address)',
35
+ 'function PROTOCOL_FEE_BPS() view returns (uint256)',
36
+ 'function PLATFORM_TAX_BPS() view returns (uint256)',
37
+ 'error UnknownToken()', 'error AlreadyGraduated()', 'error ZeroAmount()', 'error GradAmtTooSmall()', 'error Slippage()',
38
+ 'error MigratorNotSet()', 'error ZeroAddress()', 'error NotAgent()', 'error WalletCapReached()', 'error RefPoolRequired()',
39
+ 'error NoBridgePool()', 'error BridgeNotWbnb()', 'error BadTaxConfig(string reason)',
40
+ 'error PoolNotFactory()', 'error QuoteNotInPool()', 'error PoolNoLiquidity()', 'error ThinReferencePool()', 'error NotContract()',
41
+ 'error UnknownPoolType()', 'error QuoteNotAllowed()', 'error RouterNeedsGate()', 'error V3FactoryNotSet()', 'error GradAmtZero()',
42
+ 'error VaultFloorTooHigh()',
43
+ 'event Created(address indexed token, address indexed creator, string name, string symbol, string logoURI, address indexed quote, address refPair, uint256 gradAmt)',
44
+ 'event TaxConfigured(address indexed token, uint16 buyTaxBps, uint16 sellTaxBps, uint16 dividendBps, address indexed recipient)',
45
+ 'event Buy(address indexed token, address indexed buyer, uint256 quoteIn, uint256 protocolFee, uint256 tax, uint256 tokensOut, uint256 realQuote)',
46
+ 'event Sell(address indexed token, address indexed seller, uint256 tokensIn, uint256 protocolFee, uint256 tax, uint256 quoteOut, uint256 realQuote)',
47
+ 'event Graduated(address indexed token, address indexed pair, address quote, uint256 tokenAmount, uint256 quoteAmount)',
48
+ ])
49
+ const TAX_ROUTER_ABI = parseAbi([
50
+ 'function owedOf(address token) view returns (uint256)',
51
+ 'function release(address token) returns (uint256)',
52
+ 'error NothingOwed()',
53
+ 'event TaxDistributed(address indexed token, uint8 phase, uint256 amount, uint256 toPlatform, uint256 toDividend, uint256 toRecipient)',
54
+ 'event TaxPaid(address indexed token, address indexed recipient, address indexed quote, uint256 amount)',
55
+ 'event TaxOwed(address indexed token, address indexed recipient, address indexed quote, uint256 amount)',
56
+ ])
57
+ const REGISTRY_ABI = parseAbi([
58
+ 'function isListed(address recipient) view returns (bool)',
59
+ 'function entries(address recipient) view returns (bool listed, uint64 listedAt, string label)',
60
+ ])
61
+ const ERC20_ABI = parseAbi([
62
+ 'function balanceOf(address) view returns (uint256)',
63
+ 'function symbol() view returns (string)',
64
+ ])
65
+ const PAIR_ABI = parseAbi(['function getReserves() view returns (uint112, uint112, uint32)', 'function token0() view returns (address)'])
66
+
67
+ // what to tell the agent for each pad error
68
+ const HINTS = {
69
+ BadTaxConfig: 'a tax setting is out of bounds, or the recipient is neither you nor on the whitelist: see reason',
70
+ NotAgent: 'this wallet holds no Agent ID; V3 create and curve trades are Agent-only',
71
+ WalletCapReached: 'this wallet already holds the per-wallet cap (1% of supply)',
72
+ AlreadyGraduated: 'the token graduated: re-run gang v3 buy / sell, which now trade it in the PancakeSwap pool',
73
+ Slippage: 'the price moved past --slippage; re-run to re-quote',
74
+ RefPoolRequired: 'a non-WBNB --quote needs --ref-pool: a Pancake V2 pair or V3 pool that holds the quote token',
75
+ QuoteNotInPool: '--ref-pool does not contain the --quote token',
76
+ ThinReferencePool: '--ref-pool is too shallow; pick the deepest pool for this quote',
77
+ GradAmtTooSmall: 'the quote token is priced so high that the graduation amount rounds below the minimum',
78
+ NothingOwed: 'the router owes this token\'s recipient nothing',
79
+ QuoteNotAllowed: '--quote must be WBNB or an 18-decimal ERC-20 token (leave it out to use WBNB)',
80
+ PoolNotFactory: '--ref-pool is not a PancakeSwap V2 pair / V3 pool',
81
+ NotContract: '--ref-pool is not a contract',
82
+ UnknownPoolType: '--ref-pool is not a PancakeSwap V2 pair / V3 pool',
83
+ PoolNoLiquidity: '--ref-pool has no liquidity',
84
+ V3FactoryNotSet: 'V3 pools are not enabled on this pad yet; use a Pancake V2 pair as --ref-pool',
85
+ NoBridgePool: '--ref-pool pairs the quote with a token that has no bridge to WBNB here; pick a pool against WBNB (or USDT on mainnet)',
86
+ BridgeNotWbnb: 'the bridge pool for this --ref-pool is misconfigured; pick a pool against WBNB',
87
+ GradAmtZero: 'the quote token is priced so high that the target rounds to 0',
88
+ RouterNeedsGate: 'the router path is off while the Agent gate is off; re-run with --use-wbnb to buy directly',
89
+ // not a create flag: the shared dividend vault's holding floor for this pad was raised above what V3 allows (10,000 tokens)
90
+ VaultFloorTooHigh: 'the platform\'s dividend vault requires a larger holding than V3 dividends allow (10,000 tokens), so a dividend token can\'t be created right now; --dividend 0 still works, or ask gang to fix the vault setting',
91
+ }
92
+
93
+ export function v3Ctx(testnet) {
94
+ const net = V3_NETS[testnet ? 'testnet' : 'mainnet']
95
+ if (!net.pad) throw new Error(NOT_ON_MAINNET)
96
+ const client = createPublicClient({ chain: net.chain, transport: http(net.rpc) })
97
+ return { ...net, client, read: (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args }) }
98
+ }
99
+
100
+ const symbolOf = async (ctx, token) => { try { return await ctx.read(token, ERC20_ABI, 'symbol') } catch { return null } }
101
+ const pct = (bps) => Number(bps) / 100
102
+ const VIRTUAL_TOKEN = 1_066_666_666_666_666_666_666_666_667n
103
+
104
+ /** Revert name (+ BadTaxConfig reason) of a failed call, or null when it was the RPC. */
105
+ const revertOf = (e) => revertIn(e, [PAD_ABI, TAX_ROUTER_ABI, GANG_ROUTER_ABI, MERGED_ERRORS_ABI], { ...MERGED_HINTS, ...ROUTER_HINTS, ...HINTS })
106
+
107
+ /** eth_call `data` from `from`: { result } when it passes, { failed } with the revert (or RPC error) otherwise. */
108
+ async function simulate(ctx, from, to, abi, functionName, args, value = 0n) {
109
+ try {
110
+ const { result } = await ctx.client.simulateContract({ account: from, address: to, abi, functionName, args, value })
111
+ return { result }
112
+ } catch (e) {
113
+ const r = revertOf(e)
114
+ return { failed: r ? { reverted: true, ...r } : { reverted: false, error: e.shortMessage || e.message.split('\n')[0] } }
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Where the tax split / owed pushes (router) and the recipient whitelist (registry) live: one TaxHandlerV3 since the
120
+ * contract merge, so both are taxHandler(). 旧部署兼容,第 6 套上线后可删: older pads keep them on router() /
121
+ * recipientRegistry() (TaxRouterV3 / RecipientRegistry).
122
+ */
123
+ async function taxContracts(ctx) {
124
+ if (!ctx._tax) {
125
+ ctx._tax = (async () => {
126
+ const handler = await ctx.read(ctx.pad, PAD_ABI, 'taxHandler')
127
+ const [router, registry] = await Promise.all(['router', 'recipientRegistry']
128
+ .map((fn) => ctx.read(ctx.pad, PAD_ABI, fn).catch(() => handler)))
129
+ return { handler, router, registry }
130
+ })()
131
+ }
132
+ return ctx._tax
133
+ }
134
+
135
+ /** The call hit a contract without this function: a bare revert (no error data) or an empty return. */
136
+ function noSuchFunction(e) {
137
+ if (e?.walk?.((x) => x?.name === 'ContractFunctionZeroDataError')) return true
138
+ const r = e?.walk?.((x) => x?.name === 'ContractFunctionRevertedError')
139
+ return !!r && !r.data && (!r.raw || r.raw === '0x')
140
+ }
141
+
142
+ /** The whitelist label of a recipient; null when unlisted or the whitelist keeps no entries() (labels only in events). */
143
+ async function labelOf(ctx, registry, recipient) {
144
+ try { return (await ctx.read(registry, REGISTRY_ABI, 'entries', [recipient]))[2] || null } catch { return null }
145
+ }
146
+
147
+ /** A token's curve and tax as the pad stores them, plus the recipient's whitelist label. null when not a V3 token. */
148
+ export async function curveOf(ctx, token) {
149
+ const c = await ctx.read(ctx.pad, PAD_ABI, 'curves', [token])
150
+ const [creator, quote, realQuote, sold, gradAmt, virtualQuote, graduated, buyTaxBps, sellTaxBps, dividendBps, pair] = c
151
+ if (creator === zeroAddress) return null
152
+ // the recipient is not in curves(): it lives on the tax handler's route, read back through taxConfigOf
153
+ const [{ handler, router, registry }, remaining, tax] = await Promise.all([
154
+ taxContracts(ctx),
155
+ ctx.read(ctx.pad, PAD_ABI, 'graduationRemaining', [token]), ctx.read(ctx.pad, PAD_ABI, 'taxConfigOf', [token]),
156
+ ])
157
+ const recipient = tax[3] === zeroAddress ? null : tax[3]
158
+ // §22: the creator may be its own recipient without a listing; say so, the platform did not review it
159
+ const recipientIsCreator = !!recipient && recipient.toLowerCase() === creator.toLowerCase()
160
+ let label = null
161
+ if (recipient && !recipientIsCreator) label = await labelOf(ctx, registry, recipient)
162
+ const owed = await ctx.read(router, TAX_ROUTER_ABI, 'owedOf', [token])
163
+ // graduation zeroes realQuote (it went into the pool): a graduated token is priced from the pair reserves
164
+ let price = ((virtualQuote + realQuote) * 10n ** 18n) / (VIRTUAL_TOKEN - sold)
165
+ let reserveTokenRaw = 0n
166
+ if (graduated && pair !== zeroAddress) {
167
+ const [[r0, r1], t0] = await Promise.all([ctx.read(pair, PAIR_ABI, 'getReserves'), ctx.read(pair, PAIR_ABI, 'token0')])
168
+ const [rT, rQ] = t0.toLowerCase() === token.toLowerCase() ? [r0, r1] : [r1, r0]
169
+ price = rT > 0n ? (rQ * 10n ** 18n) / rT : 0n
170
+ reserveTokenRaw = rT
171
+ }
172
+ return {
173
+ token, creator, quote, quoteSymbol: await symbolOf(ctx, quote), taxHandler: handler, router, registry,
174
+ graduated, pair: pair === zeroAddress ? null : pair,
175
+ buyTaxPct: pct(buyTaxBps), sellTaxPct: pct(sellTaxBps), dividendPct: pct(dividendBps), recipient, recipientLabel: label,
176
+ ...(recipientIsCreator && { recipientIsCreator: true, recipientNote: 'the recipient is the creator itself, not reviewed by the platform' }),
177
+ raised: await units(ctx, quote, graduated ? gradAmt : realQuote), graduationAt: await units(ctx, quote, gradAmt),
178
+ toGraduation: await units(ctx, quote, remaining),
179
+ progressPct: graduated ? 100 : gradAmt > 0n ? Number((realQuote * 10_000n) / gradAmt) / 100 : 0,
180
+ priceQuote: await units(ctx, quote, price), priceFrom: graduated ? 'pool' : 'curve', priceRaw: price,
181
+ owedToRecipient: await units(ctx, quote, owed), owedRaw: owed, reserveTokenRaw,
182
+ }
183
+ }
184
+
185
+ /** `token` funding for `need` toward the pad (wrap BNB, then approve): a step object, or null when ready. */
186
+ const fundingStep = (ctx, me, token, need, sym, approveStep, rawOnly, summarize) =>
187
+ fund(ctx, { me, token, need, spender: ctx.pad, sym, approveStep }, rawOnly, summarize)
188
+
189
+ /** The pad's fee constants: the curve fee (the only charge on a curve trade), the platform's part of every pool tax (bps of volume). */
190
+ async function feesOf(ctx) {
191
+ ctx.fees ??= Promise.all([ctx.read(ctx.pad, PAD_ABI, 'PROTOCOL_FEE_BPS'), ctx.read(ctx.pad, PAD_ABI, 'PLATFORM_TAX_BPS')])
192
+ .then(([curve, platform]) => ({ curveFeePct: pct(curve), platformTaxPct: pct(platform) }))
193
+ return ctx.fees
194
+ }
195
+
196
+ const addrArg = (opt, fail, name, what) => {
197
+ const v = opt(name)
198
+ if (!isAddress(v ?? '')) fail(`--${name} must be ${what} (0x…)`, { got: v ?? null })
199
+ return v
200
+ }
201
+ const parseSlippage = (fail, raw) => parseNum(fail, 'slippage', raw, 0, 50)
202
+ const minOf = (x, slippagePct) => (x * BigInt(Math.round((100 - slippagePct) * 100))) / 10_000n
203
+
204
+ const SUBS = ['list', 'info', 'status', 'create', 'buy', 'sell', 'claim', 'income', 'release', 'tx']
205
+ const USAGE = 'usage: gang v3 <list|info|status|create|buy|sell|claim|income|release|tx> [--testnet] — see gang help'
206
+
207
+ /**
208
+ * Entry for `gang v3 <sub>`. io: { opt, flag, out, fail, summarize, resolveLogo, checkMeta, resolveMeta } from index.js
209
+ * (summarize = summarizePreview, so previews look like every other command's).
210
+ */
211
+ export async function runV3(sub, { opt, flag, out, fail, summarize, resolveLogo, checkMeta, resolveMeta }) {
212
+ if (!SUBS.includes(sub)) return fail(USAGE, { got: sub ?? null })
213
+ // agent notes are matched to the server's V2 / Soft trades; V3 txs are not matched there yet
214
+ if (flag('note') || flag('follow')) return fail('--note / --follow are not supported on gang v3 yet: the server cannot match them to a V3 tx; leave them out')
215
+ const testnet = flag('testnet')
216
+ const net = testnet ? ' --testnet' : ''
217
+ const rawOnly = flag('calldata')
218
+
219
+ if (sub === 'list' || sub === 'income') {
220
+ const recipient = sub === 'income' ? addrArg(opt, fail, 'recipient', 'the tax recipient address') : null
221
+ const status = opt('status', 'all')
222
+ if (sub === 'list' && !['curve', 'graduated', 'all'].includes(status)) fail('--status must be curve, graduated or all')
223
+ const path = recipient ? `/v3/recipients/${recipient.toLowerCase()}?limit=20` : `/v3/tokens?status=${status}&limit=30`
224
+ const r = await fetch(`${API}${path}`, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(10_000) })
225
+ .then((x) => (x.ok ? x.json() : { error: x.status })).catch((e) => ({ error: e.message }))
226
+ if (r.error) return out({ success: false, error: `gang.fun V3 data unavailable (${r.error})`, hint: `use gang v3 info --token <addr>${net} for one token (reads the chain)` })
227
+ // the quote's own decimals from gang.fun (18 when an older server does not send them)
228
+ const f = (x, d) => (x == null ? null : formatUnits(BigInt(x), d ?? 18))
229
+ if (recipient) {
230
+ return out({
231
+ success: true, recipient, label: r.label, whitelist: r.whitelist ?? (r.listed ? 'listed' : null), chainId: r.chainId,
232
+ totals: (r.totals || []).map((x) => ({ quote: x.symbol || x.quote, paid: f(x.paid, x.decimals) })),
233
+ tokens: (r.tokens || []).map((x) => ({ token: x.token, symbol: x.symbol, quote: x.quoteSymbol, paid: f(x.paid, x.quoteDecimals), payments: x.payments, lastPaidAt: x.lastPaidAt })),
234
+ recentPayments: (r.payments?.items || []).map((x) => ({ token: x.token, amount: f(x.amount, x.decimals), ts: x.ts, tx: x.tx })),
235
+ })
236
+ }
237
+ return out({ success: true, status, items: (r.items || []).map((x) => ({
238
+ token: x.address, symbol: x.symbol, buyTaxPct: pct(x.buyTaxBps), sellTaxPct: pct(x.sellTaxBps), dividendPct: pct(x.dividendBps),
239
+ recipient: x.recipient, recipientLabel: x.recipientLabel, quote: x.quoteSymbol, progressPct: x.progressPct, graduated: x.graduated,
240
+ chainId: x.chainId, link: `https://gang.fun/token/${x.address}`,
241
+ })) })
242
+ }
243
+
244
+ let ctx
245
+ try { ctx = v3Ctx(testnet) } catch (e) { return fail(e.message) }
246
+ // not a curve on this pad: on the merged pad it may be a 打新 token (kindOf), so point at `gang fair`
247
+ const notV3 = async (token) => {
248
+ const { error, kind } = await notThisKind(ctx, token, 'v3', net)
249
+ return fail(error, { token, chainId: ctx.chainId, ...(kind && { kind }) })
250
+ }
251
+
252
+ if (sub === 'info') {
253
+ const token = addrArg(opt, fail, 'token', 'a V3 token address')
254
+ const c = await curveOf(ctx, token)
255
+ if (!c) return notV3(token)
256
+ const { owedRaw: _raw, priceRaw: _p, reserveTokenRaw: rT, ...pub } = c
257
+ // in the pool the tax is held as tokens and sold in small chunks under a per-block budget
258
+ const swapBack = c.graduated && c.pair ? await swapBackState(ctx, c.taxHandler, token, rT) : null
259
+ const fees = await feesOf(ctx)
260
+ const taxNote = c.buyTaxPct > 0 || c.sellTaxPct > 0
261
+ ? `buy ${c.buyTaxPct}% / sell ${c.sellTaxPct}% tax only in the PancakeSwap pool after graduation (${fees.platformTaxPct}% of volume to the platform, then ${c.dividendPct}% of the rest to holders, the remainder to the recipient); curve trades pay only the ${fees.curveFeePct}% fee, no tax`
262
+ : `untaxed; curve trades pay the ${fees.curveFeePct}% fee`
263
+ return out({ success: true, chainId: Number(ctx.chainId), ...fees, ...pub, taxNote, ...(swapBack && { swapBack }) })
264
+ }
265
+
266
+ if (sub === 'status') {
267
+ const token = addrArg(opt, fail, 'token', 'a V3 token address')
268
+ const c = await curveOf(ctx, token)
269
+ if (!c) return notV3(token)
270
+ const slippage = parseSlippage(fail, opt('slippage', '1'))
271
+ const me = walletFor(ctx, opt('from'))
272
+ const [vault, bal] = await Promise.all([ctx.read(ctx.pad, PAD_ABI, 'dividendVault'), ctx.read(token, ERC20_ABI, 'balanceOf', [me])])
273
+ const { claimableRaw: _r, ...dividend } = c.dividendPct > 0 ? await dividendOf(ctx, vault, token, me, c.quote, c.priceRaw) : {}
274
+ // graduated: what selling the whole balance into the pool pays now (sell tax and the chunk sold first included)
275
+ const q = c.graduated && c.pair && bal > 0n ? await poolSellQuote(ctx, c.taxHandler, token, bal) : null
276
+ return out({
277
+ success: true, chainId: Number(ctx.chainId), wallet: me, token, quote: c.quoteSymbol, graduated: c.graduated,
278
+ balance: formatUnits(bal, 18), dividendPct: c.dividendPct,
279
+ ...(q && {
280
+ poolSellAll: {
281
+ expectOut: await units(ctx, c.quote, q.out), minOut: await units(ctx, c.quote, minOf(q.out, slippage)), slippagePct: slippage,
282
+ quotedWith: q.quotedWith, hint: POOL_SELL_HINT,
283
+ },
284
+ }),
285
+ ...(c.dividendPct > 0 ? { dividend } : { note: 'this token pays no dividend (dividend 0%)' }),
286
+ })
287
+ }
288
+
289
+ if (sub === 'claim') {
290
+ const token = addrArg(opt, fail, 'token', 'a V3 token address')
291
+ const c = await curveOf(ctx, token)
292
+ if (!c) return notV3(token)
293
+ if (!(c.dividendPct > 0)) return fail('this token pays no dividend (dividend 0%)', { token })
294
+ const me = walletFor(ctx, opt('from'))
295
+ const vault = await ctx.read(ctx.pad, PAD_ABI, 'dividendVault')
296
+ const d = await dividendOf(ctx, vault, token, me, c.quote, c.priceRaw)
297
+ if (d.claimableRaw === 0n) {
298
+ return fail('nothing to claim yet', {
299
+ ...(d.creditedUnsoldTokens && Number(d.creditedUnsoldTokens) > 0 && { creditedUnsoldEstimate: d.creditedUnsoldEstimate, note: d.note }),
300
+ hint: `gang v3 status --token ${token}${testnet ? ' --testnet' : ''}`,
301
+ })
302
+ }
303
+ const sim = await simulate(ctx, me, vault, VAULT_ABI, 'claim', [token])
304
+ if (sim.failed) return out({ success: false, step: 'claim', ...sim.failed })
305
+ return out(step(ctx, { from: me, to: vault, data: encodeFunctionData({ abi: VAULT_ABI, functionName: 'claim', args: [token] }) }, {
306
+ step: 'claim', chainId: Number(ctx.chainId), token, amount: d.claimable, quote: c.quoteSymbol,
307
+ ...(d.creditedUnsoldEstimate && Number(d.creditedUnsoldTokens) > 0 && { stillCreditedUnsold: d.creditedUnsoldEstimate }),
308
+ }, rawOnly, summarize))
309
+ }
310
+
311
+ if (sub === 'create') {
312
+ const name = opt('name'), symbol = opt('symbol')
313
+ if (!name || !symbol) fail('--name and --symbol are required')
314
+ checkMeta() // a bad --website / --twitter / --description fails here, before any chain read or upload
315
+ if (!flag('buy-tax') || !flag('sell-tax')) fail('--buy-tax and --sell-tax are required (each 1–5, in %; e.g. --buy-tax 2 --sell-tax 2)')
316
+ const buyTax = parseNum(fail, 'buy-tax', opt('buy-tax'), 1, 5)
317
+ const sellTax = parseNum(fail, 'sell-tax', opt('sell-tax'), 1, 5)
318
+ const dividend = parseNum(fail, 'dividend', opt('dividend', '0'), 0, 100)
319
+ // everything to holders needs no recipient; otherwise the recipient gets the rest: yourself, or a whitelisted partner
320
+ const recipient = dividend === 100 && !flag('recipient') ? zeroAddress : addrArg(opt, fail, 'recipient', 'the tax recipient: your own address, or a whitelisted partner')
321
+ const quote = flag('quote') ? opt('quote') : ctx.wbnb
322
+ const refPool = flag('ref-pool') ? opt('ref-pool') : (defaultRefPool(ctx.chainId, quote) ?? zeroAddress)
323
+ if (!isAddress(quote ?? '') || !isAddress(refPool ?? '')) fail('--quote / --ref-pool must be 0x addresses')
324
+ const tax = { buyTaxBps: Math.round(buyTax * 100), sellTaxBps: Math.round(sellTax * 100), dividendBps: Math.round(dividend * 100), recipient }
325
+ const me = walletFor(ctx, opt('from'))
326
+ // the recipient may be the creator itself: checkTaxConfigFor knows who creates (a pad without it: checkTaxConfig)
327
+ const [ok, reason] = await ctx.read(ctx.pad, PAD_ABI, 'checkTaxConfigFor', [me, tax]).catch((e) => {
328
+ if (!noSuchFunction(e)) throw e
329
+ return ctx.read(ctx.pad, PAD_ABI, 'checkTaxConfig', [tax])
330
+ })
331
+ if (!ok) fail(`tax setting refused: ${reason}`, { hint: HINTS.BadTaxConfig, tax: { buyTax, sellTax, dividend, recipient } })
332
+ if (!(await ctx.read(ctx.pad, PAD_ABI, 'isAgent', [me]))) fail('this wallet holds no Agent ID; V3 create is Agent-only', { wallet: me })
333
+ // like LaunchpadV2: WBNB → 15 BNB; a fixed rate in QuoteRegistry; else --ref-pool prices the target
334
+ let gradAmt
335
+ try { gradAmt = await ctx.read(ctx.pad, PAD_ABI, 'graduationAmountOf', [quote, refPool]) } catch (e) {
336
+ return fail('cannot price the graduation target in this quote', revertOf(e) || { detail: e.shortMessage || e.message })
337
+ }
338
+ const createData = (logoURI, metaURI) =>
339
+ encodeFunctionData({ abi: PAD_ABI, functionName: 'create', args: [name, symbol, logoURI, metaURI, quote, refPool, tax] })
340
+ // the logo / metadata strings never decide a revert: dry-run without them, so a create that would fail uploads nothing
341
+ const dry = await simulate(ctx, me, ctx.pad, PAD_ABI, 'create', [name, symbol, '', '', quote, refPool, tax])
342
+ if (dry.failed) return out({ success: false, step: 'create', ...dry.failed })
343
+ const logo = await resolveLogo()
344
+ const meta = await resolveMeta({ name, symbol, logoURI: logo.logoURI })
345
+ let label = null
346
+ const selfRecipient = recipient !== zeroAddress && recipient.toLowerCase() === String(me).toLowerCase()
347
+ if (recipient !== zeroAddress && !selfRecipient) {
348
+ label = await labelOf(ctx, (await taxContracts(ctx)).registry, recipient)
349
+ }
350
+ const rest = 100 - dividend
351
+ const fees = await feesOf(ctx)
352
+ return out(step(ctx, { from: me, to: ctx.pad, data: createData(logo.logoURI, meta.metaURI) }, {
353
+ step: 'create', chainId: Number(ctx.chainId), name, symbol, logoURI: logo.logoURI, ...(logo.uploaded && { uploaded: true }),
354
+ metaURI: meta.metaURI, metadata: meta.metadata,
355
+ quote: (await symbolOf(ctx, quote)) || quote, graduationAt: await units(ctx, quote, gradAmt),
356
+ ...(refPool !== zeroAddress && quote.toLowerCase() !== String(ctx.wbnb).toLowerCase() && { refPool }),
357
+ buyTaxPct: buyTax, sellTaxPct: sellTax, dividendPct: dividend,
358
+ recipient: recipient === zeroAddress ? null : recipient, recipientLabel: selfRecipient ? 'you (the creator)' : label, tokenIfNext: dry.result,
359
+ split: `of each ${buyTax}% buy / ${sellTax}% sell tax in the PancakeSwap pool (after graduation; the curve charges no tax): ${fees.platformTaxPct}% of volume to the platform, then ${dividend}% of the rest to holders, ${rest}% to the recipient in the quote`
360
+ + (dividend > 0 ? '; if no holder has 10,000+ tokens when a tax is paid, the holders\' part goes to the recipient too' : ''),
361
+ note: `tax rates and recipient are fixed at create. Curve trades pay only the ${fees.curveFeePct}% fee, no tax: the tax starts in the pool after graduation. tokenIfNext changes if another create lands first: the real address is the Created event from \`gang v3 tx --hash …\`.`,
362
+ }, rawOnly, summarize))
363
+ }
364
+
365
+ if (sub === 'buy') {
366
+ const token = addrArg(opt, fail, 'token', 'a V3 token address')
367
+ const c = await curveOf(ctx, token)
368
+ if (!c) return notV3(token)
369
+ if (c.graduated) {
370
+ // graduated: buy in the PancakeSwap pool through GangRouter (the buy tax applies; no Agent gate, no wallet cap)
371
+ if (!c.pair) return fail('the pad marks this token graduated but has no PancakeSwap pair on record: nothing to trade it on', { token })
372
+ if (!flag('amount') && !flag('bnb')) fail('--amount <quote (or BNB) to spend> required')
373
+ const raw = parseAmount(fail, flag('amount') ? 'amount' : 'bnb', opt('amount') ?? opt('bnb'))
374
+ return out(await poolTrade(ctx, {
375
+ side: 'buy', token, quote: c.quote, me: walletFor(ctx, opt('from')), amount: raw,
376
+ slippage: parseSlippage(fail, opt('slippage', '1')), buyTaxBps: Math.round(c.buyTaxPct * 100),
377
+ sellTaxBps: Math.round(c.sellTaxPct * 100), handler: c.taxHandler, payBnb: poolPayBnb(ctx, c.quote, flag),
378
+ base: { kind: 'v3', pair: c.pair },
379
+ }, { rawOnly, summarize, revertOf }))
380
+ }
381
+ const me = walletFor(ctx, opt('from'))
382
+ if (!(await ctx.read(ctx.pad, PAD_ABI, 'isAgent', [me]))) fail('this wallet holds no Agent ID; curve trades are Agent-only', { wallet: me })
383
+ if ((await ctx.read(ctx.pad, PAD_ABI, 'walletRoom', [token, me])) === 0n) fail(HINTS.WalletCapReached)
384
+ if (!flag('amount') && !flag('bnb')) fail('--amount <quote to spend> required (the 1% curve fee included; no tax on the curve)')
385
+ const raw = parseAmount(fail, flag('amount') ? 'amount' : 'bnb', opt('amount') ?? opt('bnb'))
386
+ const quoteIn = parseUnits(raw, await decimalsOf(ctx, c.quote))
387
+ const slippage = parseSlippage(fail, opt('slippage', '1'))
388
+ const extra = (tokensOut, minOut) => ({
389
+ step: 'buy', chainId: Number(ctx.chainId), token, spend: raw, quote: c.quoteSymbol, expectTokens: formatUnits(tokensOut, 18),
390
+ minTokensOut: formatUnits(minOut, 18), curveTaxPct: 0, ...(c.buyTaxPct > 0 && { poolBuyTaxPct: c.buyTaxPct }),
391
+ })
392
+ // WBNB-quoted: pay native BNB through the router in one tx (it wraps; tokens land in this wallet; a cap / graduation
393
+ // refund comes back from the pad to this wallet as WBNB, not BNB)
394
+ if (viaRouter(ctx, c.quote, flag)) {
395
+ const native = await ctx.client.getBalance({ address: me })
396
+ if (native < quoteIn + GAS_RESERVE) {
397
+ fail('not enough BNB for this buy (native BNB must also cover gas)', {
398
+ haveBnb: formatUnits(native, 18), needBnb: raw, reserveForGas: formatUnits(GAS_RESERVE, 18), hint: 'add --use-wbnb to pay from WBNB instead',
399
+ })
400
+ }
401
+ const sim = await simulate(ctx, me, ctx.router, GANG_ROUTER_ABI, 'v3BuyBNB', [token, 0n, routerDeadline()], quoteIn)
402
+ if (sim.failed) return out({ success: false, step: 'buy', via: 'router', ...sim.failed })
403
+ const minOut = minOf(sim.result, slippage)
404
+ const data = encodeFunctionData({ abi: GANG_ROUTER_ABI, functionName: 'v3BuyBNB', args: [token, minOut, routerDeadline()] })
405
+ return out(step(ctx, { from: me, to: ctx.router, data, value: quoteIn }, {
406
+ ...extra(sim.result, minOut), via: 'router', curveFeePct: (await feesOf(ctx)).curveFeePct,
407
+ note: 'paid in BNB through GangRouter; spend includes the 1% curve fee (no tax on the curve; the buy tax applies only in the pool after graduation); at the 1% wallet cap or the graduation cut the excess comes back to this wallet as WBNB (not BNB)',
408
+ }, rawOnly, summarize))
409
+ }
410
+ const pre = await fundingStep(ctx, me, c.quote, quoteIn, c.quoteSymbol, 'approve-quote', rawOnly, summarize)
411
+ if (pre) return out(pre)
412
+ const sim = await simulate(ctx, me, ctx.pad, PAD_ABI, 'buy', [token, quoteIn, 0n])
413
+ if (sim.failed) return out({ success: false, step: 'buy', ...sim.failed })
414
+ const minOut = minOf(sim.result, slippage)
415
+ return out(step(ctx, { from: me, to: ctx.pad, data: encodeFunctionData({ abi: PAD_ABI, functionName: 'buy', args: [token, quoteIn, minOut] }) }, {
416
+ ...extra(sim.result, minOut), curveFeePct: (await feesOf(ctx)).curveFeePct,
417
+ note: 'spend includes the 1% curve fee (no tax on the curve; the buy tax applies only in the pool after graduation); at the 1% wallet cap the excess is refunded',
418
+ }, rawOnly, summarize))
419
+ }
420
+
421
+ if (sub === 'sell') {
422
+ const token = addrArg(opt, fail, 'token', 'a V3 token address')
423
+ const c = await curveOf(ctx, token)
424
+ if (!c) return notV3(token)
425
+ if (flag('all') === !!opt('amount')) fail('pass exactly one of --amount <tokens> or --all')
426
+ const slippage = parseSlippage(fail, opt('slippage', '1'))
427
+ if (c.graduated) {
428
+ // graduated: sell into the PancakeSwap pool through GangRouter, minOut from the handler's previewSellNextBlock
429
+ // (sell tax and the chunk of held tax sold first included). No wallet: the quote alone, as before.
430
+ if (!c.pair) return fail('the pad marks this token graduated but has no PancakeSwap pair on record: nothing to sell it into', { token })
431
+ let me = null
432
+ try { me = walletFor(ctx, opt('from')) } catch { /* no wallet configured */ }
433
+ if (me) {
434
+ return out(await poolTrade(ctx, {
435
+ side: 'sell', token, quote: c.quote, me, amount: flag('all') ? null : parseAmount(fail, 'amount', opt('amount')), all: flag('all'),
436
+ slippage, buyTaxBps: Math.round(c.buyTaxPct * 100), sellTaxBps: Math.round(c.sellTaxPct * 100), handler: c.taxHandler,
437
+ swapBackGas: c.sellTaxPct > 0, toBnb: flag('to-bnb'), base: { kind: 'v3', pair: c.pair },
438
+ }, { rawOnly, summarize, revertOf }))
439
+ }
440
+ if (flag('all')) return fail('--all needs a wallet (or --from <addr>) whose balance can be read', { pair: c.pair })
441
+ const amount = parseUnits(parseAmount(fail, 'amount', opt('amount')), 18)
442
+ const q = await poolSellQuote(ctx, c.taxHandler, token, amount)
443
+ return fail('no wallet to build the pool sell for: pass --from <addr> (quote only)', {
444
+ pair: c.pair, ...(q && {
445
+ poolQuote: {
446
+ amount: formatUnits(amount, 18), expectOut: await units(ctx, c.quote, q.out), minOut: await units(ctx, c.quote, minOf(q.out, slippage)),
447
+ slippagePct: slippage, quote: c.quoteSymbol, sellTaxPct: c.sellTaxPct, quotedWith: q.quotedWith,
448
+ },
449
+ hint: POOL_SELL_HINT,
450
+ }),
451
+ ...(!q && { note: 'no pool quote: the tax handler did not answer (RPC); re-run for one' }),
452
+ })
453
+ }
454
+ const me = walletFor(ctx, opt('from'))
455
+ const bal = await ctx.read(token, ERC20_ABI, 'balanceOf', [me])
456
+ if (bal === 0n) fail('no balance to sell')
457
+ const amount = flag('all') ? bal : parseUnits(parseAmount(fail, 'amount', opt('amount')), 18)
458
+ if (amount > bal) fail('amount exceeds balance', { balance: formatUnits(bal, 18) })
459
+ const pre = await fundingStep(ctx, me, token, amount, c.quoteSymbol, 'approve', rawOnly, summarize)
460
+ if (pre) return out(pre)
461
+ const sim = await simulate(ctx, me, ctx.pad, PAD_ABI, 'sell', [token, amount, 0n])
462
+ if (sim.failed) return out({ success: false, step: 'sell', ...sim.failed })
463
+ const minOut = minOf(sim.result, slippage)
464
+ return out(step(ctx, { from: me, to: ctx.pad, data: encodeFunctionData({ abi: PAD_ABI, functionName: 'sell', args: [token, amount, minOut] }) }, {
465
+ step: 'sell', chainId: Number(ctx.chainId), token, amount: formatUnits(amount, 18), quote: c.quoteSymbol,
466
+ expectOut: await units(ctx, c.quote, sim.result), minOut: await units(ctx, c.quote, minOut), curveTaxPct: 0,
467
+ ...(c.sellTaxPct > 0 && { poolSellTaxPct: c.sellTaxPct }),
468
+ curveFeePct: (await feesOf(ctx)).curveFeePct, note: 'expectOut is after the 1% curve fee only (no tax on the curve; the sell tax applies only in the pool after graduation)',
469
+ }, rawOnly, summarize))
470
+ }
471
+
472
+ if (sub === 'release') {
473
+ const token = addrArg(opt, fail, 'token', 'a V3 token address')
474
+ const c = await curveOf(ctx, token)
475
+ if (!c) return notV3(token)
476
+ if (c.owedRaw === 0n) return fail('nothing owed: every tax payment reached the recipient', { recipient: c.recipient })
477
+ const me = walletFor(ctx, opt('from'))
478
+ const sim = await simulate(ctx, me, c.router, TAX_ROUTER_ABI, 'release', [token])
479
+ if (sim.failed) return out({ success: false, step: 'release', ...sim.failed, hint: 'the recipient may still refuse transfers of this quote; try again later' })
480
+ return out(step(ctx, { from: me, to: c.router, data: encodeFunctionData({ abi: TAX_ROUTER_ABI, functionName: 'release', args: [token] }) }, {
481
+ step: 'release', chainId: Number(ctx.chainId), token, recipient: c.recipient, recipientLabel: c.recipientLabel,
482
+ amount: c.owedToRecipient, quote: c.quoteSymbol, note: 'anyone may release; the quote goes to the recipient, not to the caller',
483
+ }, rawOnly, summarize))
484
+ }
485
+
486
+ if (sub === 'tx') {
487
+ const hash = opt('hash')
488
+ if (!/^0x[0-9a-fA-F]{64}$/.test(hash ?? '')) fail('--hash must be a tx hash')
489
+ const r = await ctx.client.waitForTransactionReceipt({ hash, timeout: 120_000 })
490
+ const events = []
491
+ for (const log of r.logs) {
492
+ for (const abi of [PAD_ABI, TAX_ROUTER_ABI, VAULT_ABI]) {
493
+ try {
494
+ const ev = decodeEventLog({ abi, data: log.data, topics: log.topics })
495
+ events.push({ event: ev.eventName, ...Object.fromEntries(Object.entries(ev.args).map(([k, v]) => [k, typeof v === 'bigint' ? v.toString() : v])) })
496
+ break
497
+ } catch { /* other contract / event */ }
498
+ }
499
+ }
500
+ return out({ success: true, chainId: Number(ctx.chainId), hash, status: r.status, block: Number(r.blockNumber), events, explorer: `${ctx.explorer}/tx/${hash}` })
501
+ }
502
+
503
+ return fail(USAGE, { got: sub })
504
+ }