@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/steps.js ADDED
@@ -0,0 +1,290 @@
1
+ import { parseAbi, encodeFunctionData, decodeErrorResult, formatUnits, parseEther } from 'viem'
2
+ import { bawPreview } from './baw.js'
3
+
4
+ /**
5
+ * Shared by `gang fair` and `gang v3` (each on its own chain via `ctx`: { chainId, client, read, wbnb }): revert decoding,
6
+ * the write step (raw tx or baw preview), quote funding (wrap / approve), flag parsing and token units.
7
+ */
8
+ const ERC20_ABI = parseAbi([
9
+ 'function balanceOf(address) view returns (uint256)',
10
+ 'function allowance(address, address) view returns (uint256)',
11
+ 'function approve(address, uint256) returns (bool)',
12
+ 'function decimals() view returns (uint8)',
13
+ ])
14
+ const WBNB_ABI = parseAbi(['function deposit() payable'])
15
+
16
+ /**
17
+ * GangRouter (gang-launchpad src/router; a proxy, so its address stays): stateless entry in front of the pad. With
18
+ * native BNB it wraps and calls the pad for the caller — no wrap / approve round for WBNB-quoted tokens and sales. A V3
19
+ * buy's cap / graduation refund goes from the pad straight to the caller as WBNB (not BNB); fairMintBNB returns
20
+ * unspent BNB as BNB. The pool* functions trade a graduated / launched token on PancakeSwap (see pool.js):
21
+ * the token / proceeds go to the caller; poolBuy / poolSell pull the quote / token, so approve the router first.
22
+ */
23
+ export const ROUTER_ABI = parseAbi([
24
+ 'function v3Buy(address token, uint256 quoteIn, uint256 minTokensOut, uint256 deadline) returns (uint256)',
25
+ 'function v3BuyBNB(address token, uint256 minTokensOut, uint256 deadline) payable returns (uint256)',
26
+ 'function fairMint(address token, uint256 shares, uint256 minShares, uint256 deadline) returns (uint256)',
27
+ 'function fairMintBNB(address token, uint256 shares, uint256 minShares, uint256 deadline) payable returns (uint256)',
28
+ 'function poolBuyBNB(address token, address quote, uint256 minTokensOut, uint256 deadline) payable returns (uint256 tokensOut)',
29
+ 'function poolBuy(address token, address quote, uint256 quoteIn, uint256 minTokensOut, uint256 deadline) returns (uint256)',
30
+ 'function poolSell(address token, address quote, uint256 amountIn, uint256 minQuoteOut, uint256 deadline) returns (uint256)',
31
+ 'function poolSellForBNB(address token, address quote, uint256 amountIn, uint256 minBnbOut, uint256 deadline) returns (uint256)',
32
+ 'function pad() view returns (address)', 'function wbnb() view returns (address)', 'function pancakeRouter() view returns (address)',
33
+ // errors as in gang-launchpad src/router/GangRouter.sol (feat/upgradeable-router)
34
+ 'error Expired()', 'error NotDeployed()', 'error UnknownToken()', 'error NotWbnbQuote()', 'error NotEnoughValue()',
35
+ 'error OnlyWbnb()', 'error BnbTransferFailed()', 'error ZeroAmount()',
36
+ 'error TooLittleReceived(uint256 received, uint256 minimum)',
37
+ ])
38
+ /** What to tell the agent for each router error. */
39
+ export const ROUTER_HINTS = {
40
+ Expired: 'the tx landed after its deadline (30 min after the preview): re-run the command for a fresh one',
41
+ TooLittleReceived: 'the pool pays less than the minimum (args: [received, minimum], raw units): the price moved, or the tax chunk sold first took more. Re-run to re-quote, or raise --slippage',
42
+ ZeroAmount: 'the amount is 0 (or nothing arrived from the wallet)',
43
+ NotEnoughValue: 'the BNB sent does not cover the amount',
44
+ UnknownToken: 'the router does not know this token on its pad (the other kind of token, or not a gang token)',
45
+ }
46
+ /** Router deadline: half an hour, for a preview that is executed a few minutes later. */
47
+ export const routerDeadline = () => BigInt(Math.floor(Date.now() / 1000) + 1800)
48
+ /** Native BNB kept back for gas on a router call, as `gang buy` keeps for wrap + approve + buy. */
49
+ export const GAS_RESERVE = parseEther('0.002')
50
+ /** The router path applies: a router on this chain, a WBNB quote, and no --use-wbnb. */
51
+ export const viaRouter = (ctx, quote, flag) => !!ctx.router && quote.toLowerCase() === ctx.wbnb.toLowerCase() && !flag('use-wbnb')
52
+
53
+ /** Token decimals, read once per ctx (18 when the token does not say). */
54
+ export async function decimalsOf(ctx, token) {
55
+ ctx.decimals ??= new Map()
56
+ const k = token.toLowerCase()
57
+ if (!ctx.decimals.has(k)) {
58
+ let d = 18
59
+ try { d = Number(await ctx.read(token, ERC20_ABI, 'decimals')) } catch { /* 18 */ }
60
+ ctx.decimals.set(k, d)
61
+ }
62
+ return ctx.decimals.get(k)
63
+ }
64
+ export const units = async (ctx, token, amount) => formatUnits(amount, await decimalsOf(ctx, token))
65
+
66
+ /** Revert name (+ the first arg, e.g. a reason string) of a failed call against any of `abis`, or null when it was the RPC. */
67
+ export function revertOf(e, abis, hints = {}) {
68
+ const raw = e?.walk?.((x) => typeof x?.data === 'string' && x.data.startsWith('0x'))?.data
69
+ if (raw && raw.length >= 10) {
70
+ for (const abi of abis) {
71
+ try {
72
+ const d = decodeErrorResult({ abi, data: raw })
73
+ return {
74
+ error: d.errorName, ...(d.args?.length && { reason: d.args[0] }),
75
+ ...(d.args?.length > 1 && { args: d.args.map((a) => (typeof a === 'bigint' ? a.toString() : a)) }), hint: hints[d.errorName],
76
+ }
77
+ } catch { /* not this contract */ }
78
+ }
79
+ }
80
+ const named = e?.walk?.((x) => x?.data?.errorName)?.data?.errorName
81
+ if (named) return { error: named, hint: hints[named] }
82
+ if (e?.walk?.((x) => x?.code === 3 || /revert/i.test(x?.details || x?.shortMessage || ''))) return { error: 'execution reverted' }
83
+ return null
84
+ }
85
+
86
+ /**
87
+ * A write step: either the raw tx (--calldata: no baw call; e.g. to sign elsewhere) or a baw preview.
88
+ * Returns the object the command prints.
89
+ */
90
+ export function step(ctx, { from, to, data, value = 0n }, extra, rawOnly, summarize) {
91
+ if (rawOnly) {
92
+ return { success: true, ...extra, tx: { chainId: Number(ctx.chainId), from, to, data, value: value.toString() } }
93
+ }
94
+ const p = bawPreview({ from, to, value, data, chainId: ctx.chainId })
95
+ // only an error about the chain itself: a generic network failure is not a reason to fall back to --calldata
96
+ if (!p.success && new RegExp(`binanceChainId|unsupported chain|chain not supported|chain(?:\\s*id)?\\W{0,3}${ctx.chainId}\\b`, 'i')
97
+ .test(JSON.stringify(p.error || ''))) {
98
+ return {
99
+ success: false, ...extra, error: p.error,
100
+ hint: `the Agentic Wallet may not sign on chain ${ctx.chainId}: re-run with --calldata to get the raw tx`,
101
+ }
102
+ }
103
+ return summarize(p, extra)
104
+ }
105
+
106
+ /**
107
+ * `token` funding for `need` toward `spender`: wrap BNB first (WBNB), then approve. A step object, or null when ready.
108
+ * approveStep names the approve step in the output (`approve-quote` for a quote, `approve` for selling a token).
109
+ */
110
+ export async function fundingStep(ctx, { me, token, need, spender, sym, approveStep = 'approve-quote' }, rawOnly, summarize) {
111
+ const bal = await ctx.read(token, ERC20_ABI, 'balanceOf', [me])
112
+ if (bal < need) {
113
+ if (token.toLowerCase() !== ctx.wbnb.toLowerCase()) {
114
+ return { success: false, error: `not enough ${sym || 'quote'}`, have: await units(ctx, token, bal), need: await units(ctx, token, need) }
115
+ }
116
+ const missing = need - bal
117
+ const native = await ctx.client.getBalance({ address: me })
118
+ if (native < missing) {
119
+ return { success: false, error: 'not enough BNB', haveBnb: formatUnits(native, 18), needBnb: formatUnits(missing, 18) }
120
+ }
121
+ return step(ctx, { from: me, to: token, data: encodeFunctionData({ abi: WBNB_ABI, functionName: 'deposit' }), value: missing },
122
+ { step: 'wrap-bnb', wrap: formatUnits(missing, 18), note: 'after executing, re-run the same command' }, rawOnly, summarize)
123
+ }
124
+ if ((await ctx.read(token, ERC20_ABI, 'allowance', [me, spender])) < need) {
125
+ return step(ctx, { from: me, to: token, data: encodeFunctionData({ abi: ERC20_ABI, functionName: 'approve', args: [spender, need] }) },
126
+ { step: approveStep, approve: await units(ctx, token, need), ...(approveStep !== 'approve-quote' && { token }), spender, note: 'after executing, re-run the same command' },
127
+ rawOnly, summarize)
128
+ }
129
+ return null
130
+ }
131
+
132
+ export const parseNum = (fail, name, raw, min, max) => {
133
+ const n = Number(raw)
134
+ if (raw == null || !Number.isFinite(n) || n < min || n > max) fail(`--${name} must be a number between ${min} and ${max}`, { got: raw })
135
+ return n
136
+ }
137
+ export const parseIntIn = (fail, name, raw, min, max) => {
138
+ const n = Number(raw)
139
+ if (!Number.isInteger(n) || n < min || n > max) fail(`--${name} must be an integer between ${min} and ${max}`, { got: raw })
140
+ return n
141
+ }
142
+ /** A positive decimal amount flag (e.g. --amount 0.5), as written: callers scale it with the token's decimals. */
143
+ export const parseAmount = (fail, name, raw) => {
144
+ if (raw == null || !/^(\d+(\.\d*)?|\.\d+)$/.test(String(raw).trim()) || !(Number(raw) > 0)) {
145
+ fail(`--${name} must be a positive number (e.g. 0.5)`, { got: raw ?? null })
146
+ }
147
+ return String(raw).trim()
148
+ }
149
+
150
+ /**
151
+ * Reference pool `create` uses when --ref-pool is left out: mainnet USDT → Pancake V3 USDT/WBNB 0.01% (the deepest;
152
+ * its 30-min TWAP prices the graduation target / raise). Other quotes need an explicit --ref-pool.
153
+ */
154
+ const DEFAULT_REF_POOLS = {
155
+ 56: { '0x55d398326f99059ff775485246999027b3197955': '0x172fcD41E0913e95784454622d1c3724f546f849' },
156
+ }
157
+ export const defaultRefPool = (chainId, quote) => DEFAULT_REF_POOLS[Number(chainId)]?.[String(quote || '').toLowerCase()] || null
158
+
159
+ /** The shared dividend vault (V3 and 打新). */
160
+ export const VAULT_ABI = parseAbi([
161
+ 'function pending(address token, address user) view returns (uint256)',
162
+ 'function pendingUnits(address token, address user) view returns (uint256)',
163
+ 'function shares(address token, address user) view returns (uint256)',
164
+ 'function claim(address token) returns (uint256)',
165
+ 'error NothingToClaim()', 'error UnknownToken()',
166
+ 'event DividendClaimed(address indexed token, address indexed user, uint256 amount)',
167
+ ])
168
+
169
+ /**
170
+ * The pool-tax handler of a graduated V3 / launched 打新 token (GangTaxHandler since the merge, one for both kinds; before
171
+ * it TaxHandlerV3 / HolderRewardHandler; all PostGradHandlerBase).
172
+ * The tax is held in the token and sold in chunks: each token has one budget per block, at most 0.5% of the pair's token
173
+ * side as it stood at the block's first chunk, shared by the chunk a taxed sell triggers (≤ 25% of that sell) and the
174
+ * keeper; no chunk sells more than 2% below that block-start price.
175
+ */
176
+ export const POOL_HANDLER_ABI = parseAbi([
177
+ 'function previewSellNextBlock(address token, uint256 amountIn) view returns (uint256 quoteOut, uint256 chunk)',
178
+ 'function previewSell(address token, uint256 amountIn) view returns (uint256 quoteOut, uint256 chunk)',
179
+ 'function accruedTax(address token) view returns (uint256)',
180
+ 'function maxChunk(address token) view returns (uint256)',
181
+ 'function windowOf(address token) view returns (uint64 blockNumber, uint96 used)',
182
+ 'function refOf(address token) view returns (uint112 refT, uint112 refQ)',
183
+ 'function CHUNK_MAX_BPS() view returns (uint256)',
184
+ 'function MAX_PRICE_DROP_BPS() view returns (uint256)',
185
+ ])
186
+
187
+ /**
188
+ * The call reverted with no data, or returned nothing ("0x"): the function is not on that contract (an older
189
+ * deployment). An RPC failure (timeout, HTTP error) or a revert with a reason is not that.
190
+ */
191
+ export function isMissingFunction(e) {
192
+ if (e?.walk?.((x) => x?.name === 'ContractFunctionZeroDataError' || x?.name === 'AbiDecodingZeroDataError')) return true
193
+ const raw = e?.walk?.((x) => typeof x?.data === 'string')?.data
194
+ if (raw && raw !== '0x') return false
195
+ return !!e?.walk?.((x) => x?.code === 3 || x?.name === 'ExecutionRevertedError')
196
+ }
197
+
198
+ /**
199
+ * What selling `amountIn` of `token` into its pool pays (after the sell tax, the chunk it triggers sold first), for the
200
+ * seller's amountOutMin: `previewSellNextBlock` — the tx lands in a later block, with a fresh chunk budget. Only a
201
+ * handler from before it (no such function) falls back to `previewSell` (this block). null on any other failure (RPC,
202
+ * a revert with a reason) or when neither answers: no quote rather than a wrong one.
203
+ */
204
+ export async function poolSellQuote(ctx, handler, token, amountIn) {
205
+ const ask = async (fn) => {
206
+ const [out, chunk] = await ctx.read(handler, POOL_HANDLER_ABI, fn, [token, amountIn])
207
+ return { out, chunk, quotedWith: fn }
208
+ }
209
+ try {
210
+ return await ask('previewSellNextBlock')
211
+ } catch (e) {
212
+ if (!isMissingFunction(e)) return null
213
+ }
214
+ try { return await ask('previewSell') } catch { return null }
215
+ }
216
+
217
+ /**
218
+ * The swap-back state of a graduated token, all read at the latest block:
219
+ * - unsoldTaxTokens: tax held and not sold yet;
220
+ * - budgetPerBlockTokens: the per-block budget a new block opens (CHUNK_MAX_BPS of the pool's token side now);
221
+ * - budgetLeftInLatestBlockTokens: what the latest block's budget still allows — the whole budget when no chunk ran
222
+ * in it (windowOf is an earlier block), else the budget fixed at that block's first chunk minus what was sold since;
223
+ * - nextChunkTokens (maxChunk): the largest chunk that could be sold in the latest block — the least of the unsold
224
+ * tax, what the handler holds of it, and the budget left. A sell's own chunk is further capped at 25% of that sell.
225
+ * Handlers from before the per-block budget (one chunk per block) have no windowOf: `budget: 'one chunk per block (older handler)'`.
226
+ */
227
+ export async function swapBackState(ctx, handler, token, reserveToken) {
228
+ let blockNumber
229
+ try { blockNumber = await ctx.client.getBlockNumber() } catch { return null }
230
+ const at = (fn, args = []) => ctx.client.readContract({ address: handler, abi: POOL_HANDLER_ABI, functionName: fn, args, blockNumber })
231
+ const [unsold, next, win, ref, chunkBps, dropBps] = await Promise.all([
232
+ at('accruedTax', [token]).catch(() => null),
233
+ at('maxChunk', [token]).catch(() => null),
234
+ at('windowOf', [token]).catch(() => null),
235
+ at('refOf', [token]).catch(() => null),
236
+ at('CHUNK_MAX_BPS').catch(() => 50n),
237
+ at('MAX_PRICE_DROP_BPS').catch(() => null),
238
+ ])
239
+ if (unsold == null) return null
240
+ const base = { unsoldTaxTokens: formatUnits(unsold, 18) }
241
+ if (!win) return { ...base, budget: 'one chunk per block (older handler)' }
242
+ const [winBlock, used] = win
243
+ const budget = (reserveToken * chunkBps) / 10_000n
244
+ let left = budget
245
+ if (winBlock === blockNumber) {
246
+ // a chunk ran in the latest block: its budget was fixed by the reserves at that block's first chunk attempt
247
+ const cap = ref && ref[0] > 0n ? (ref[0] * chunkBps) / 10_000n : budget
248
+ left = used >= cap ? 0n : cap - used
249
+ }
250
+ const drop = dropBps != null ? Number(dropBps) / 100 : 2
251
+ return {
252
+ ...base,
253
+ budgetPerBlockTokens: formatUnits(budget, 18),
254
+ budgetLeftInLatestBlockTokens: formatUnits(left, 18),
255
+ nextChunkTokens: next == null ? null : formatUnits(next, 18),
256
+ latestBlock: Number(blockNumber),
257
+ lastChunkBlock: Number(winBlock) || null,
258
+ ...(dropBps != null && { pausesBelowBlockStartPct: drop }),
259
+ note: `each block, the chunks sold from the held tax (those triggered by sells and the keeper's together) add up to at most ${Number(chunkBps) / 100}% of the pool's token side; `
260
+ + `no chunk is sold while the price is more than ${drop}% below where the block started. Each new block opens a fresh budget. `
261
+ + 'nextChunkTokens is the largest chunk the latest block still allows (unsold tax and budget left, whichever is less); a sell triggers at most 25% of its own amount.',
262
+ }
263
+ }
264
+
265
+ /** Hint for selling a graduated / launched token on PancakeSwap with the minOut of a pool quote (poolSellQuote) as amountOutMin. */
266
+ export const POOL_SELL_HINT = '`gang v3 sell` / `gang fair sell` build this sell through GangRouter (poolSell / --to-bnb poolSellForBNB, minOut as here); '
267
+ + 'or sell on PancakeSwap with swapExactTokensForTokensSupportingFeeOnTransferTokens (the token takes its sell tax in the transfer), passing minOut (poolQuote.minOut / poolSellAll.minOut, set by --slippage) as amountOutMin so the sell reverts rather than fills below it; '
268
+ + 'the quote includes the small chunk of tax the handler sells right before a sell and assumes the tx lands in a new block (previewSellNextBlock). '
269
+ + 'Let the wallet estimate gas, or give at least 750k: a sell that triggers a chunk uses about 660k (about 330k without), and too little gas reverts with SwapBackOutOfGas'
270
+
271
+ /**
272
+ * `me`'s dividend / holder reward on a V3 or 打新 token from the shared vault: `pending` = settled quote, claimable now; `pendingUnits` = tax
273
+ * already credited to `me` when it was paid (in tax tokens) whose batch is not sold yet — it becomes claimable quote
274
+ * once sold, and is valued here at `priceRaw` (quote wei per token) as an estimate only. Vaults from before the
275
+ * tax-time crediting have no pendingUnits: those report null.
276
+ */
277
+ export async function dividendOf(ctx, vault, token, me, quote, priceRaw) {
278
+ const [pending, share, unitsRaw] = await Promise.all([
279
+ ctx.read(vault, VAULT_ABI, 'pending', [token, me]),
280
+ ctx.read(vault, VAULT_ABI, 'shares', [token, me]),
281
+ ctx.read(vault, VAULT_ABI, 'pendingUnits', [token, me]).catch(() => null),
282
+ ])
283
+ return {
284
+ claimable: await units(ctx, quote, pending), claimableRaw: pending,
285
+ dividendShare: formatUnits(share, 18),
286
+ creditedUnsoldTokens: unitsRaw == null ? null : formatUnits(unitsRaw, 18),
287
+ creditedUnsoldEstimate: unitsRaw == null || !priceRaw ? null : await units(ctx, quote, (unitsRaw * priceRaw) / 10n ** 18n),
288
+ ...(unitsRaw > 0n && { note: 'creditedUnsold: tax credited to you when it was paid, claimable once that batch is sold (estimate at the current price)' }),
289
+ }
290
+ }