@gangfun/cli 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -3
- package/README.zh-CN.md +49 -3
- package/package.json +2 -2
- package/src/abi.js +17 -0
- package/src/chain.js +14 -5
- package/src/discover.js +6 -3
- package/src/fair.js +506 -0
- package/src/feed.js +166 -0
- package/src/gangpad.js +108 -0
- package/src/index.js +281 -18
- 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/fair.js
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import { createPublicClient, http, parseAbi, encodeFunctionData, decodeEventLog, formatUnits, parseEther, parseUnits, isAddress, zeroAddress } from 'viem'
|
|
2
|
+
import { bawAddress } from './baw.js'
|
|
3
|
+
import { defaultRefPool, dividendOf, fundingStep as fund, parseAmount, parseIntIn, parseNum, poolSellQuote, POOL_SELL_HINT, revertOf as revertIn, ROUTER_ABI, ROUTER_HINTS, routerDeadline, step, swapBackState, units, viaRouter, GAS_RESERVE } from './steps.js'
|
|
4
|
+
import { API } from './discover.js'
|
|
5
|
+
import { UA } from './version.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 fair …` — 打新 (fair mint): one fixed price per share both ways (mint buys at it, withdraw sells back at it);
|
|
11
|
+
* the last share launches the token on PancakeSwap at that price. No deadline, no refund: a sale stays open until it
|
|
12
|
+
* fills. Contracts: gang-launchpad GangLaunchpad (merged pad: 打新 sales and V3 curves on one address; see gangpad.js).
|
|
13
|
+
* Mainnet needs a pad address (GANG_PAD or the one shipped in gangpad.js); without it mainnet answers "no mainnet address".
|
|
14
|
+
*/
|
|
15
|
+
export const FAIR_NETS = padNets('fair')
|
|
16
|
+
|
|
17
|
+
const PAD_ABI = parseAbi([
|
|
18
|
+
'function create(string name, string symbol, string logoURI, string metaURI, address quote, address refPool, (uint256 raiseBnb, uint16 capBps, uint16 holdBps, uint16 mintFeeBps, uint16 exitFeeBps) cfg, uint256 devShares) returns (address)',
|
|
19
|
+
'function mint(address token, uint256 shares, uint256 minShares) returns (uint256)',
|
|
20
|
+
'function withdraw(address token, uint256 shares, uint256 minPayout) returns (uint256)',
|
|
21
|
+
'function sales(address token) view returns (address creator, uint64 progressAt, uint16 capShares, uint16 holdBps, address quote, uint16 sold, bool launched, uint16 mintFeeBps, uint16 exitFeeBps, address gate, uint128 sharePrice, address pair, address refPool, uint16 markSold)',
|
|
22
|
+
'function positions(address token, address user) view returns (uint16 shares)',
|
|
23
|
+
'function previewWithdraw(address token, address user, uint256 shares) view returns (uint256 payout, uint256 fee)',
|
|
24
|
+
'function exitFeeBpsOf(address token) view returns (uint256)',
|
|
25
|
+
'function STALE_AFTER() view returns (uint256)',
|
|
26
|
+
'function status(address token) view returns (uint8)',
|
|
27
|
+
'function previewMint(address token, address user, uint256 shares) view returns (uint256 accepted, uint256 cost, uint256 fee)',
|
|
28
|
+
'function previewCreate(address quote, address refPool, uint256 raiseBnb) view returns (uint256 raiseQuote, uint256 sharePrice)',
|
|
29
|
+
'function checkConfig((uint256 raiseBnb, uint16 capBps, uint16 holdBps, uint16 mintFeeBps, uint16 exitFeeBps) cfg, uint256 devShares) pure returns (bool ok, string reason)',
|
|
30
|
+
'function taxHandler() view returns (address)',
|
|
31
|
+
'function dividendVault() view returns (address)',
|
|
32
|
+
'function agentIdRegistry() view returns (address)',
|
|
33
|
+
'function agentGateEnabled() view returns (bool)',
|
|
34
|
+
'function TOKENS_PER_SHARE() view returns (uint256)',
|
|
35
|
+
'function TOTAL_SHARES() view returns (uint256)',
|
|
36
|
+
'error BadConfig(string reason)', 'error UnknownToken()', 'error NotMinting()', 'error ZeroAmount()',
|
|
37
|
+
'error BadAmount()', 'error Slippage()', 'error WalletCapReached()', 'error NotAgent()', 'error OutOfGas()',
|
|
38
|
+
'error MigratorNotSet()', 'error ZeroAddress()', 'error RefPoolRequired()', 'error NoBridgePool()', 'error BridgeNotWbnb()',
|
|
39
|
+
'error PriceTooSmall()', 'error FeeOnTransferQuote()', 'error VaultFloorTooHigh()', 'error QuoteNotAllowed()',
|
|
40
|
+
'error RouterNeedsGate()', 'error NotContract()', 'error UnknownPoolType()', 'error V3FactoryNotSet()',
|
|
41
|
+
'error PoolNotFactory()', 'error QuoteNotInPool()', 'error PoolNoLiquidity()', 'error ThinReferencePool()', 'error GradAmtZero()',
|
|
42
|
+
'event Created(address indexed token, address indexed creator, string name, string symbol, string logoURI, address indexed quote, address refPool, uint256 raiseBnb, uint256 sharePrice, uint256 capShares, uint256 holdBps, uint256 mintFeeBps, uint256 exitFeeBps)',
|
|
43
|
+
'event Minted(address indexed token, address indexed user, uint256 shares, uint256 cost, uint256 fee, uint256 userShares, uint256 sold)',
|
|
44
|
+
'event Withdrawn(address indexed token, address indexed user, uint256 shares, uint256 payout, uint256 fee, uint256 userShares, uint256 sold)',
|
|
45
|
+
'event Launched(address indexed token, address indexed pair, address quote, uint256 tokenAmount, uint256 quoteAmount)',
|
|
46
|
+
])
|
|
47
|
+
const HANDLER_ABI = parseAbi([
|
|
48
|
+
'function isOut(address token, address user) view returns (bool)',
|
|
49
|
+
'function eligibleShare(address token, address user) view returns (uint256)',
|
|
50
|
+
])
|
|
51
|
+
const VAULT_ABI = parseAbi([
|
|
52
|
+
'function pending(address token, address user) view returns (uint256)',
|
|
53
|
+
'function totalShares(address token) view returns (uint256)',
|
|
54
|
+
'function claim(address token) returns (uint256)',
|
|
55
|
+
'event DividendClaimed(address indexed token, address indexed user, uint256 amount)',
|
|
56
|
+
])
|
|
57
|
+
const ERC20_ABI = parseAbi([
|
|
58
|
+
'function balanceOf(address) view returns (uint256)',
|
|
59
|
+
'function symbol() view returns (string)',
|
|
60
|
+
])
|
|
61
|
+
// GangToken's pool tax rates (immutable; 0 / 0 on an untaxed token)
|
|
62
|
+
const TAX_ABI = parseAbi(['function buyTaxBps() view returns (uint16)', 'function sellTaxBps() view returns (uint16)'])
|
|
63
|
+
const ERC721_ABI = parseAbi(['function balanceOf(address) view returns (uint256)'])
|
|
64
|
+
const PAIR_ABI = parseAbi(['function getReserves() view returns (uint112, uint112, uint32)', 'function token0() view returns (address)'])
|
|
65
|
+
|
|
66
|
+
/** A launched sale's pool reserves as [token side, quote side] ([0, 0] before launch). */
|
|
67
|
+
async function poolReserves(ctx, token, pair) {
|
|
68
|
+
if (!pair) return [0n, 0n]
|
|
69
|
+
const [[r0, r1], t0] = await Promise.all([ctx.read(pair, PAIR_ABI, 'getReserves'), ctx.read(pair, PAIR_ABI, 'token0')])
|
|
70
|
+
return t0.toLowerCase() === token.toLowerCase() ? [r0, r1] : [r1, r0]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Pool price of a launched sale's token, in quote wei per token (0 before launch / an empty pool). */
|
|
74
|
+
async function poolPriceRaw(ctx, token, pair) {
|
|
75
|
+
const [rT, rQ] = await poolReserves(ctx, token, pair)
|
|
76
|
+
return rT > 0n ? (rQ * 10n ** 18n) / rT : 0n
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const STATUS = ['none', 'minting', 'launched']
|
|
80
|
+
// what to tell the agent for each pad error
|
|
81
|
+
const HINTS = {
|
|
82
|
+
BadConfig: 'a rule is out of bounds: see reason',
|
|
83
|
+
NotAgent: 'this wallet holds no Agent ID; fair mint is Agent-only',
|
|
84
|
+
NotMinting: 'the sale already launched: trade the token in the pool with gang fair buy / gang fair sell',
|
|
85
|
+
WalletCapReached: 'this wallet already holds the per-wallet cap of shares',
|
|
86
|
+
Slippage: 'fewer shares are left than --min-shares (someone filled first)',
|
|
87
|
+
BadAmount: '--shares is more than this wallet holds in the sale',
|
|
88
|
+
FeeOnTransferQuote: 'the quote token takes a fee on transfer; it cannot be used',
|
|
89
|
+
RefPoolRequired: 'this quote needs --ref-pool (a Pancake pool pricing it)',
|
|
90
|
+
PriceTooSmall: 'the raise in this quote is too small to split into 10,000 shares',
|
|
91
|
+
QuoteNotAllowed: '--quote must be WBNB or an 18-decimal ERC-20 token (leave it out to use WBNB)',
|
|
92
|
+
PoolNotFactory: '--ref-pool is not a PancakeSwap V2 pair / V3 pool',
|
|
93
|
+
NotContract: '--ref-pool is not a contract',
|
|
94
|
+
UnknownPoolType: '--ref-pool is not a PancakeSwap V2 pair / V3 pool',
|
|
95
|
+
PoolNoLiquidity: '--ref-pool has no liquidity',
|
|
96
|
+
V3FactoryNotSet: 'V3 pools are not enabled on this pad yet; use a Pancake V2 pair as --ref-pool',
|
|
97
|
+
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)',
|
|
98
|
+
BridgeNotWbnb: 'the bridge pool for this --ref-pool is misconfigured; pick a pool against WBNB',
|
|
99
|
+
GradAmtZero: 'the quote token is priced so high that the raise rounds to 0',
|
|
100
|
+
QuoteNotInPool: '--ref-pool does not contain the --quote token',
|
|
101
|
+
ThinReferencePool: '--ref-pool is too shallow; pick the deepest pool for this quote',
|
|
102
|
+
RouterNeedsGate: 'this sale has no Agent gate, so the router path is off; re-run with --use-wbnb to mint directly',
|
|
103
|
+
// not a create flag: the shared dividend vault's holding floor for this pad was raised above what 打新 allows
|
|
104
|
+
VaultFloorTooHigh: 'the platform\'s dividend vault requires a larger holding than fair-mint rewards allow, so no sale can be created right now; ask gang to fix the vault setting',
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function fairCtx(testnet) {
|
|
108
|
+
const net = FAIR_NETS[testnet ? 'testnet' : 'mainnet']
|
|
109
|
+
if (!net.pad) throw new Error(NOT_ON_MAINNET)
|
|
110
|
+
const client = createPublicClient({ chain: net.chain, transport: http(net.rpc) })
|
|
111
|
+
return { ...net, client, read: (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args }) }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const symbolOf = async (ctx, token) => { try { return await ctx.read(token, ERC20_ABI, 'symbol') } catch { return null } }
|
|
115
|
+
|
|
116
|
+
/** Wallet address on the fair chain; the Agentic Wallet's EVM address is the same on every chain, so 56 is the fallback. */
|
|
117
|
+
export function walletFor(ctx, from) {
|
|
118
|
+
if (from) return from
|
|
119
|
+
try { return bawAddress(ctx.chainId) } catch { return bawAddress('56') }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Revert name (+ BadConfig reason) of a failed eth_call, or null when it was the RPC. */
|
|
123
|
+
const revertOf = (e) => revertIn(e, [PAD_ABI, ROUTER_ABI, MERGED_ERRORS_ABI], { ...MERGED_HINTS, ...ROUTER_HINTS, ...HINTS })
|
|
124
|
+
|
|
125
|
+
/** Dry-run `data` from `from`; null when it would pass. */
|
|
126
|
+
async function dryRun(ctx, from, to, data, value = 0n) {
|
|
127
|
+
try {
|
|
128
|
+
await ctx.client.call({ account: from, to, data, value })
|
|
129
|
+
return null
|
|
130
|
+
} catch (e) {
|
|
131
|
+
const r = revertOf(e)
|
|
132
|
+
return r ? { reverted: true, ...r } : { reverted: false, error: e.shortMessage || e.message.split('\n')[0] }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A sale as the pad stores it, plus derived fields. null when the token is not a fair-mint sale on this pad. */
|
|
137
|
+
export async function saleOf(ctx, token) {
|
|
138
|
+
const s = await ctx.read(ctx.pad, PAD_ABI, 'sales', [token])
|
|
139
|
+
if (s[0] === zeroAddress) return null
|
|
140
|
+
const [creator, progressAt, capShares, holdBps, quote, sold, launched, mintFeeBps, exitFeeBps, gate, sharePrice, pair] = s
|
|
141
|
+
const [st, exitNowBps, staleAfter] = await Promise.all([
|
|
142
|
+
ctx.read(ctx.pad, PAD_ABI, 'status', [token]), ctx.read(ctx.pad, PAD_ABI, 'exitFeeBpsOf', [token]),
|
|
143
|
+
ctx.read(ctx.pad, PAD_ABI, 'STALE_AFTER'),
|
|
144
|
+
])
|
|
145
|
+
const status = STATUS[Number(st)]
|
|
146
|
+
const left = 10_000 - Number(sold)
|
|
147
|
+
return {
|
|
148
|
+
token, status, creator, quote, quoteSymbol: await symbolOf(ctx, quote), gate,
|
|
149
|
+
capShares: Number(capShares), holdPct: Number(holdBps) / 100, sold: Number(sold), totalShares: 10_000,
|
|
150
|
+
// no deadline: what a fill still needs, and how long until withdraws stop paying the exit fee
|
|
151
|
+
sharesLeft: left, minWalletsLeft: Math.ceil(left / Number(capShares)),
|
|
152
|
+
// the stale clock: restarts only on progress (sold up 10 shares since the last restart), not on every mint
|
|
153
|
+
progressAt: Number(progressAt), progressIso: new Date(Number(progressAt) * 1000).toISOString(),
|
|
154
|
+
stale: !launched && Number(exitNowBps) === 0,
|
|
155
|
+
exitFeeFreeIn: launched ? 0 : Math.max(0, Number(progressAt) + Number(staleAfter) - Math.floor(Date.now() / 1000)),
|
|
156
|
+
mintFeePct: Number(mintFeeBps) / 100, exitFeePct: Number(exitFeeBps) / 100, exitFeeNowPct: Number(exitNowBps) / 100,
|
|
157
|
+
exitFeeBps: Number(exitFeeBps),
|
|
158
|
+
sharePriceRaw: sharePrice, sharePrice: await units(ctx, quote, sharePrice), launched,
|
|
159
|
+
pair: pair === zeroAddress ? null : pair,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Quote funding for `need` toward the pad (wrap BNB, then approve): a step object, or null when ready. */
|
|
164
|
+
const fundingStep = (ctx, me, quote, need, sym, rawOnly, summarize) =>
|
|
165
|
+
fund(ctx, { me, token: quote, need, spender: ctx.pad, sym }, rawOnly, summarize)
|
|
166
|
+
|
|
167
|
+
const tokenArg = (opt, fail) => {
|
|
168
|
+
const t = opt('token')
|
|
169
|
+
if (!isAddress(t ?? '')) fail('--token must be the sale\'s token address (0x…)', { got: t ?? null })
|
|
170
|
+
return t
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `x` less `slippagePct`% (the pool quote's minOut, as `gang v3 sell`). */
|
|
174
|
+
const minOf = (x, slippagePct) => (x * BigInt(Math.round((100 - slippagePct) * 100))) / 10_000n
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Live position of `me` in a sale (reward fields once launched). After launch any wallet holding the token — a minter
|
|
178
|
+
* or one that only bought in the pool — gets poolSellAll: what selling its whole balance pays, and the minOut for it at
|
|
179
|
+
* `slippage`%.
|
|
180
|
+
*/
|
|
181
|
+
async function positionOf(ctx, token, me, sale, slippage) {
|
|
182
|
+
const shares = await ctx.read(ctx.pad, PAD_ABI, 'positions', [token, me])
|
|
183
|
+
const tps = await ctx.read(ctx.pad, PAD_ABI, 'TOKENS_PER_SHARE')
|
|
184
|
+
const baseline = BigInt(shares) * tps
|
|
185
|
+
const pos = { shares: Number(shares), baselineTokens: formatUnits(baseline, 18) }
|
|
186
|
+
if (!sale.launched) {
|
|
187
|
+
if (shares) {
|
|
188
|
+
const [payout] = await ctx.read(ctx.pad, PAD_ABI, 'previewWithdraw', [token, me, BigInt(shares)])
|
|
189
|
+
pos.sellBackAll = await units(ctx, sale.quote, payout) // what `gang fair withdraw --all` pays now
|
|
190
|
+
}
|
|
191
|
+
return pos
|
|
192
|
+
}
|
|
193
|
+
const [handler, vault, bal] = await Promise.all([
|
|
194
|
+
ctx.read(ctx.pad, PAD_ABI, 'taxHandler'), ctx.read(ctx.pad, PAD_ABI, 'dividendVault'), ctx.read(token, ERC20_ABI, 'balanceOf', [me]),
|
|
195
|
+
])
|
|
196
|
+
// what selling the whole balance into the pool pays (the 1% tax and the chunk sold first included, as of the next block)
|
|
197
|
+
const q = sale.pair && bal > 0n ? await poolSellQuote(ctx, handler, token, bal) : null
|
|
198
|
+
const sellAll = q && {
|
|
199
|
+
poolSellAll: {
|
|
200
|
+
expectOut: await units(ctx, sale.quote, q.out), minOut: await units(ctx, sale.quote, minOf(q.out, slippage)), slippagePct: slippage,
|
|
201
|
+
quotedWith: q.quotedWith, hint: POOL_SELL_HINT,
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
if (!shares) {
|
|
205
|
+
return { ...pos, balance: formatUnits(bal, 18), ...sellAll, reward: 'none', note: 'did not mint in this sale: the holder reward is only for minters' }
|
|
206
|
+
}
|
|
207
|
+
const [out, weight, reward, total] = await Promise.all([
|
|
208
|
+
ctx.read(handler, HANDLER_ABI, 'isOut', [token, me]),
|
|
209
|
+
ctx.read(handler, HANDLER_ABI, 'eligibleShare', [token, me]),
|
|
210
|
+
poolPriceRaw(ctx, token, sale.pair).then((p) => dividendOf(ctx, vault, token, me, sale.quote, p)),
|
|
211
|
+
ctx.read(vault, VAULT_ABI, 'totalShares', [token]),
|
|
212
|
+
])
|
|
213
|
+
const line = (baseline * BigInt(Math.round(sale.holdPct * 100))) / 10_000n
|
|
214
|
+
return {
|
|
215
|
+
...pos,
|
|
216
|
+
balance: formatUnits(bal, 18),
|
|
217
|
+
...sellAll,
|
|
218
|
+
reward: out ? 'out' : 'earning',
|
|
219
|
+
holdLineTokens: formatUnits(line, 18),
|
|
220
|
+
sellableBeforeOut: !out && bal > line ? formatUnits(bal - line, 18) : '0',
|
|
221
|
+
poolSharePct: total > 0n ? Number((weight * 1_000_000n) / total) / 10_000 : 0,
|
|
222
|
+
pendingReward: reward.claimable,
|
|
223
|
+
// tax credited to me when it was paid, claimable once its batch is sold (null on vaults without tax-time crediting)
|
|
224
|
+
creditedUnsoldTokens: reward.creditedUnsoldTokens, creditedUnsoldEstimate: reward.creditedUnsoldEstimate,
|
|
225
|
+
...(out ? { note: 'out for good: fell below the hold line once; buying back does not restore it' } : reward.note && { note: reward.note }),
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Entry for `gang fair <sub>`. io: { args, opt, flag, out, fail, summarize } from index.js
|
|
231
|
+
* (summarize = summarizePreview, so previews look like every other command's).
|
|
232
|
+
*/
|
|
233
|
+
const SUBS = ['list', 'info', 'status', 'create', 'mint', 'withdraw', 'buy', 'sell', 'claim', 'tx']
|
|
234
|
+
/** withdraw reverts below the preview minus this (bps): it catches an exit fee coming back (≥ 1%). */
|
|
235
|
+
const WITHDRAW_TOLERANCE_BPS = 10n
|
|
236
|
+
const USAGE = 'usage: gang fair <list|info|status|create|mint|withdraw|buy|sell|claim|tx> [--testnet] — see gang help'
|
|
237
|
+
|
|
238
|
+
export async function runFair(sub, { opt, flag, out, fail, summarize, resolveLogo, checkMeta, resolveMeta }) {
|
|
239
|
+
if (!SUBS.includes(sub)) return fail(USAGE, { got: sub ?? null })
|
|
240
|
+
// agent notes are matched to curve trades / creates on the server; fair-mint txs are not indexed there yet
|
|
241
|
+
if (flag('note') || flag('follow')) return fail('--note / --follow are not supported on gang fair yet: the server cannot match them to a fair-mint tx; leave them out')
|
|
242
|
+
const testnet = flag('testnet')
|
|
243
|
+
const rawOnly = flag('calldata')
|
|
244
|
+
if (sub === 'list') {
|
|
245
|
+
const status = opt('status', 'minting')
|
|
246
|
+
if (!['minting', 'launched', 'all'].includes(status)) fail('--status must be minting, launched or all')
|
|
247
|
+
const r = await fetch(`${API}/fair/sales?status=${status}&limit=30`, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(10_000) })
|
|
248
|
+
.then((x) => (x.ok ? x.json() : { error: x.status })).catch((e) => ({ error: e.message }))
|
|
249
|
+
if (r.error) return out({ success: false, error: `gang.fun fair list unavailable (${r.error})`, hint: `use gang fair info --token <addr>${testnet ? ' --testnet' : ''} for one sale` })
|
|
250
|
+
return out({ success: true, status, items: (r.items || []).map((x) => ({
|
|
251
|
+
token: x.address, symbol: x.symbol, status: x.status, sold: x.sold, participants: x.participants,
|
|
252
|
+
sharePrice: x.sharePrice && formatUnits(BigInt(x.sharePrice), 18), quote: x.quoteSymbol, chainId: x.chainId,
|
|
253
|
+
sharesLeft: x.sharesLeft ?? null, minWalletsLeft: x.minWalletsLeft ?? null, lastMintAt: x.lastMintAt ?? null, progressAt: x.progressAt ?? null, stale: x.stale ?? null,
|
|
254
|
+
link: `https://gang.fun/token/${x.address}`,
|
|
255
|
+
})) })
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let ctx
|
|
259
|
+
try { ctx = fairCtx(testnet) } catch (e) { return fail(e.message) }
|
|
260
|
+
// not a sale on this pad: on the merged pad it may be a V3 token (kindOf), so point at `gang v3`
|
|
261
|
+
const notFair = async (token) => {
|
|
262
|
+
const { error, kind } = await notThisKind(ctx, token, 'fair', testnet ? ' --testnet' : '')
|
|
263
|
+
return fail(error, { token, chainId: ctx.chainId, ...(kind && { kind }) })
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (sub === 'info' || sub === 'status') {
|
|
267
|
+
const token = tokenArg(opt, fail)
|
|
268
|
+
const sale = await saleOf(ctx, token)
|
|
269
|
+
if (!sale) return notFair(token)
|
|
270
|
+
const { sharePriceRaw: _raw, exitFeeBps: _exit, ...pub } = sale
|
|
271
|
+
if (sub === 'info') {
|
|
272
|
+
// after launch the 1% tax is held as tokens and sold in small chunks under a per-block budget
|
|
273
|
+
const swapBack = sale.launched && sale.pair
|
|
274
|
+
? await swapBackState(ctx, await ctx.read(ctx.pad, PAD_ABI, 'taxHandler'), token, (await poolReserves(ctx, token, sale.pair))[0])
|
|
275
|
+
: null
|
|
276
|
+
return out({ success: true, chainId: Number(ctx.chainId), ...pub, ...(swapBack && { swapBack }) })
|
|
277
|
+
}
|
|
278
|
+
const slippage = parseNum(fail, 'slippage', opt('slippage', '1'), 0, 50)
|
|
279
|
+
const me = walletFor(ctx, opt('from'))
|
|
280
|
+
return out({ success: true, chainId: Number(ctx.chainId), wallet: me, sale: pub, position: await positionOf(ctx, token, me, sale, slippage) })
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (sub === 'create') {
|
|
284
|
+
const name = opt('name'), symbol = opt('symbol')
|
|
285
|
+
if (!name || !symbol) fail('--name and --symbol are required')
|
|
286
|
+
checkMeta() // a bad --website / --twitter / --description fails here, before any wrap / approve round or upload
|
|
287
|
+
const raise = parseNum(fail, 'raise', opt('raise', '15'), 5, 50)
|
|
288
|
+
if (flag('hours')) fail('--hours was removed: a fair-mint sale has no deadline and stays open until it fills')
|
|
289
|
+
const capPct = parseNum(fail, 'cap', opt('cap', '0.5'), 0.25, 1)
|
|
290
|
+
const holdPct = parseNum(fail, 'hold', opt('hold', '60'), 60, 90)
|
|
291
|
+
const mintFeePct = parseNum(fail, 'mint-fee', opt('mint-fee', '1'), 1, 3)
|
|
292
|
+
const exitFeePct = parseNum(fail, 'exit-fee', opt('exit-fee', '1'), 1, 2)
|
|
293
|
+
const devShares = parseIntIn(fail, 'shares', opt('shares', '1'), 1, Math.round(capPct * 200))
|
|
294
|
+
const quote = flag('quote') ? opt('quote') : ctx.wbnb
|
|
295
|
+
const refPool = flag('ref-pool') ? opt('ref-pool') : (defaultRefPool(ctx.chainId, quote) ?? zeroAddress)
|
|
296
|
+
if (!isAddress(quote ?? '') || !isAddress(refPool ?? '')) fail('--quote / --ref-pool must be 0x addresses')
|
|
297
|
+
const cfg = {
|
|
298
|
+
raiseBnb: parseEther(String(raise)), capBps: Math.round(capPct * 100),
|
|
299
|
+
holdBps: Math.round(holdPct * 100), mintFeeBps: Math.round(mintFeePct * 100), exitFeeBps: Math.round(exitFeePct * 100),
|
|
300
|
+
}
|
|
301
|
+
const [ok, reason] = await ctx.read(ctx.pad, PAD_ABI, 'checkConfig', [cfg, BigInt(devShares)])
|
|
302
|
+
if (!ok) fail(`rule out of bounds: ${reason}`)
|
|
303
|
+
let priced
|
|
304
|
+
try { priced = await ctx.read(ctx.pad, PAD_ABI, 'previewCreate', [quote, refPool, cfg.raiseBnb]) } catch (e) {
|
|
305
|
+
return fail('cannot price the raise in this quote', revertOf(e) || { detail: e.shortMessage || e.message })
|
|
306
|
+
}
|
|
307
|
+
const [raiseQuote, sharePrice] = priced
|
|
308
|
+
const sym = await symbolOf(ctx, quote)
|
|
309
|
+
const fee = (sharePrice * BigInt(devShares) * BigInt(cfg.mintFeeBps)) / 10_000n
|
|
310
|
+
const need = sharePrice * BigInt(devShares) + fee
|
|
311
|
+
const me = walletFor(ctx, opt('from'))
|
|
312
|
+
const pre = await fundingStep(ctx, me, quote, need, sym, rawOnly, summarize)
|
|
313
|
+
if (pre) return out(pre)
|
|
314
|
+
// logoURI is permanent on-chain: same handling as `gang create` (--logo file upload, other hosts copied to gang.fun).
|
|
315
|
+
// Done after the setup steps so a wrap / approve round does not upload the image twice.
|
|
316
|
+
const createData = (logoURI, metaURI) => encodeFunctionData({
|
|
317
|
+
abi: PAD_ABI, functionName: 'create', args: [name, symbol, logoURI, metaURI, quote, refPool, cfg, BigInt(devShares)],
|
|
318
|
+
})
|
|
319
|
+
// the logo / metadata strings never decide a revert: dry-run without them, so a create that would fail uploads nothing
|
|
320
|
+
const dry = await dryRun(ctx, me, ctx.pad, createData('', ''))
|
|
321
|
+
if (dry) return out({ success: false, step: 'create', ...dry })
|
|
322
|
+
const logo = await resolveLogo()
|
|
323
|
+
const meta = await resolveMeta({ name, symbol, logoURI: logo.logoURI })
|
|
324
|
+
const data = createData(logo.logoURI, meta.metaURI)
|
|
325
|
+
return out(step(ctx, { from: me, to: ctx.pad, data }, {
|
|
326
|
+
step: 'create', chainId: Number(ctx.chainId), name, symbol, logoURI: logo.logoURI, ...(logo.uploaded && { uploaded: true }),
|
|
327
|
+
metaURI: meta.metaURI, metadata: meta.metadata, quote: sym || quote,
|
|
328
|
+
raise: await units(ctx, quote, raiseQuote), sharePrice: await units(ctx, quote, sharePrice),
|
|
329
|
+
capShares: cfg.capBps * 2, minWallets: Math.ceil(10_000 / (cfg.capBps * 2)), holdPct, mintFeePct, exitFeePct, devShares,
|
|
330
|
+
devCost: await units(ctx, quote, need),
|
|
331
|
+
note: 'no deadline: the sale stays open until all 10,000 shares are minted, then launches. Minters can sell shares back at the mint price (minus the exit fee; none after 7 days without progress, i.e. fewer than 10 new shares). The mint fee is the platform\'s and is not returned.',
|
|
332
|
+
}, rawOnly, summarize))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (sub === 'mint') {
|
|
336
|
+
const token = tokenArg(opt, fail)
|
|
337
|
+
const want = parseIntIn(fail, 'shares', opt('shares'), 1, 10_000)
|
|
338
|
+
const sale = await saleOf(ctx, token)
|
|
339
|
+
if (!sale) return notFair(token)
|
|
340
|
+
if (sale.status !== 'minting') {
|
|
341
|
+
return fail(`sale is ${sale.status}, not minting`, { hint: `it launched: buy in the pool with gang fair buy --token ${token} --amount <quote>${testnet ? ' --testnet' : ''}` })
|
|
342
|
+
}
|
|
343
|
+
const me = walletFor(ctx, opt('from'))
|
|
344
|
+
if (sale.gate !== zeroAddress && (await ctx.read(sale.gate, ERC721_ABI, 'balanceOf', [me])) === 0n) {
|
|
345
|
+
return fail('this wallet holds no Agent ID; fair mint is Agent-only', { wallet: me })
|
|
346
|
+
}
|
|
347
|
+
const [accepted, cost, fee] = await ctx.read(ctx.pad, PAD_ABI, 'previewMint', [token, me, BigInt(want)])
|
|
348
|
+
if (accepted === 0n) return fail('no shares available to this wallet (cap reached or sold out)', { capShares: sale.capShares })
|
|
349
|
+
const minShares = flag('min-shares') ? parseIntIn(fail, 'min-shares', opt('min-shares'), 1, Number(accepted)) : Number(accepted)
|
|
350
|
+
const need = cost + fee
|
|
351
|
+
const fills = sale.sold + Number(accepted) === 10_000
|
|
352
|
+
const extra = {
|
|
353
|
+
step: 'mint', chainId: Number(ctx.chainId), token, shares: Number(accepted), ...(Number(accepted) < want && { truncatedFrom: want }),
|
|
354
|
+
cost: await units(ctx, sale.quote, cost), fee: await units(ctx, sale.quote, fee), feePct: sale.mintFeePct, quote: sale.quoteSymbol,
|
|
355
|
+
soldAfter: sale.sold + Number(accepted), ...(fills && { launches: 'this mint fills the sale and launches the token (higher gas)' }),
|
|
356
|
+
...(!fills && { minWalletsLeftAfter: Math.ceil((10_000 - sale.sold - Number(accepted)) / sale.capShares) }),
|
|
357
|
+
note: `the ${sale.mintFeePct}% mint fee goes to the platform and is not returned; selling back later pays the mint price minus the exit fee`,
|
|
358
|
+
}
|
|
359
|
+
// WBNB sale: pay native BNB through the router in one tx (it wraps; the position is still this wallet's)
|
|
360
|
+
if (viaRouter(ctx, sale.quote, flag)) {
|
|
361
|
+
const native = await ctx.client.getBalance({ address: me })
|
|
362
|
+
if (native < need + GAS_RESERVE) {
|
|
363
|
+
return fail('not enough BNB for this mint (native BNB must also cover gas)', {
|
|
364
|
+
haveBnb: formatUnits(native, 18), needBnb: formatUnits(need, 18), reserveForGas: formatUnits(GAS_RESERVE, 18), hint: 'add --use-wbnb to pay from WBNB instead',
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
const data = encodeFunctionData({ abi: ROUTER_ABI, functionName: 'fairMintBNB', args: [token, accepted, BigInt(minShares), routerDeadline()] })
|
|
368
|
+
const dry = await dryRun(ctx, me, ctx.router, data, need)
|
|
369
|
+
if (dry) return out({ success: false, step: 'mint', via: 'router', ...dry })
|
|
370
|
+
return out(step(ctx, { from: me, to: ctx.router, data, value: need }, { ...extra, via: 'router', payBnb: formatUnits(need, 18) }, rawOnly, summarize))
|
|
371
|
+
}
|
|
372
|
+
const pre = await fundingStep(ctx, me, sale.quote, need, sale.quoteSymbol, rawOnly, summarize)
|
|
373
|
+
if (pre) return out(pre)
|
|
374
|
+
const data = encodeFunctionData({ abi: PAD_ABI, functionName: 'mint', args: [token, accepted, BigInt(minShares)] })
|
|
375
|
+
const dry = await dryRun(ctx, me, ctx.pad, data)
|
|
376
|
+
if (dry) return out({ success: false, step: 'mint', ...dry })
|
|
377
|
+
return out(step(ctx, { from: me, to: ctx.pad, data }, extra, rawOnly, summarize))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (sub === 'withdraw') {
|
|
381
|
+
const token = tokenArg(opt, fail)
|
|
382
|
+
const sale = await saleOf(ctx, token)
|
|
383
|
+
if (!sale) return notFair(token)
|
|
384
|
+
if (sale.status !== 'minting') {
|
|
385
|
+
return fail(`sale is ${sale.status}: withdraw only while minting`, sale.launched ? {
|
|
386
|
+
pair: sale.pair,
|
|
387
|
+
hint: `it launched: sell in the pool with \`gang fair sell --token ${token} (--amount <tokens> | --all)${testnet ? ' --testnet' : ''}${opt('from') ? ` --from ${opt('from')}` : ''} [--slippage 1] [--to-bnb]\``
|
|
388
|
+
+ ' (through GangRouter; minOut from the tax handler\'s previewSellNextBlock)',
|
|
389
|
+
} : {})
|
|
390
|
+
}
|
|
391
|
+
const me = walletFor(ctx, opt('from'))
|
|
392
|
+
const held = await ctx.read(ctx.pad, PAD_ABI, 'positions', [token, me])
|
|
393
|
+
if (!held) return fail('this wallet has no shares in this sale')
|
|
394
|
+
const shares = flag('all') ? Number(held) : parseIntIn(fail, 'shares', opt('shares'), 1, Number(held))
|
|
395
|
+
const [payout, fee] = await ctx.read(ctx.pad, PAD_ABI, 'previewWithdraw', [token, me, BigInt(shares)])
|
|
396
|
+
// the price is fixed, so only the exit fee can move the payout: a mint landing first can restart the 7-day clock
|
|
397
|
+
// and bring a 1–2% fee back. 0.1% below the preview refuses that and nothing else.
|
|
398
|
+
const minPayout = (payout * (10_000n - WITHDRAW_TOLERANCE_BPS)) / 10_000n
|
|
399
|
+
const data = encodeFunctionData({ abi: PAD_ABI, functionName: 'withdraw', args: [token, BigInt(shares), minPayout] })
|
|
400
|
+
const dry = await dryRun(ctx, me, ctx.pad, data)
|
|
401
|
+
if (dry) return out({ success: false, step: 'withdraw', ...dry })
|
|
402
|
+
return out(step(ctx, { from: me, to: ctx.pad, data }, {
|
|
403
|
+
step: 'withdraw', chainId: Number(ctx.chainId), token, shares,
|
|
404
|
+
receive: await units(ctx, sale.quote, payout), minReceive: await units(ctx, sale.quote, minPayout),
|
|
405
|
+
exitFee: await units(ctx, sale.quote, fee), quote: sale.quoteSymbol,
|
|
406
|
+
exitFeePct: sale.exitFeeNowPct,
|
|
407
|
+
note: sale.stale
|
|
408
|
+
? `no progress for 7 days: no exit fee, the full mint price back; the ${sale.mintFeePct}% paid at mint is not returned`
|
|
409
|
+
: `mint price − ${sale.exitFeeNowPct}% exit fee (none after 7 days without 10 new shares); the ${sale.mintFeePct}% paid at mint is not returned`,
|
|
410
|
+
}, rawOnly, summarize))
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (sub === 'buy' || sub === 'sell') {
|
|
414
|
+
// after launch: trade the token in its PancakeSwap pool through GangRouter (the 打新 pool tax applies both ways)
|
|
415
|
+
const token = tokenArg(opt, fail)
|
|
416
|
+
const sale = await saleOf(ctx, token)
|
|
417
|
+
if (!sale) return notFair(token)
|
|
418
|
+
if (!sale.launched || !sale.pair) {
|
|
419
|
+
return fail(`sale is ${sale.status}: gang fair ${sub} trades the token in its pool after launch`, {
|
|
420
|
+
hint: sub === 'buy' ? 'while minting, buy shares with gang fair mint --shares <n>' : 'while minting, sell shares back with gang fair withdraw (--shares <n> | --all)',
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
const slippage = parseNum(fail, 'slippage', opt('slippage', '1'), 0, 50)
|
|
424
|
+
if (sub === 'sell' && flag('all') === !!opt('amount')) fail('pass exactly one of --amount <tokens> or --all')
|
|
425
|
+
if (sub === 'buy' && !flag('amount')) fail('--amount <quote (or BNB) to spend> required')
|
|
426
|
+
let buyTaxBps, sellTaxBps, handler
|
|
427
|
+
try {
|
|
428
|
+
;[buyTaxBps, sellTaxBps, handler] = await Promise.all([
|
|
429
|
+
ctx.read(token, TAX_ABI, 'buyTaxBps'), ctx.read(token, TAX_ABI, 'sellTaxBps'), ctx.read(ctx.pad, PAD_ABI, 'taxHandler'),
|
|
430
|
+
])
|
|
431
|
+
} catch (e) {
|
|
432
|
+
// never guess 0%: the minimum would ignore the tax
|
|
433
|
+
return fail('cannot read the token\'s pool tax rates (buyTaxBps / sellTaxBps) or the tax handler: no minimum is built without them', { token, detail: e.shortMessage || e.message.split('\n')[0], hint: 're-run' })
|
|
434
|
+
}
|
|
435
|
+
const me = walletFor(ctx, opt('from'))
|
|
436
|
+
// a minter still earning: selling past sellableBeforeOut drops it below the hold line and ends the reward for good
|
|
437
|
+
let outWarning = null
|
|
438
|
+
if (sub === 'sell') {
|
|
439
|
+
const shares = await ctx.read(ctx.pad, PAD_ABI, 'positions', [token, me])
|
|
440
|
+
if (shares && !(await ctx.read(handler, HANDLER_ABI, 'isOut', [token, me]))) {
|
|
441
|
+
const [tps, bal] = await Promise.all([ctx.read(ctx.pad, PAD_ABI, 'TOKENS_PER_SHARE'), ctx.read(token, ERC20_ABI, 'balanceOf', [me])])
|
|
442
|
+
const line = (BigInt(shares) * tps * BigInt(Math.round(sale.holdPct * 100))) / 10_000n
|
|
443
|
+
const sellable = bal > line ? bal - line : 0n
|
|
444
|
+
const amount = flag('all') ? bal : parseUnits(parseAmount(fail, 'amount', opt('amount')), 18)
|
|
445
|
+
if (amount <= bal && amount > sellable) {
|
|
446
|
+
outWarning = {
|
|
447
|
+
warning: `WARNING: selling ${formatUnits(amount, 18)} tokens goes past sellableBeforeOut (${formatUnits(sellable, 18)}): this wallet drops below its hold line (${formatUnits(line, 18)} tokens) and loses the holder reward FOR GOOD — buying back does not restore it`,
|
|
448
|
+
sellableBeforeOut: formatUnits(sellable, 18), holdLineTokens: formatUnits(line, 18), balance: formatUnits(bal, 18),
|
|
449
|
+
}
|
|
450
|
+
if (!flag('confirm-out')) {
|
|
451
|
+
return fail(outWarning.warning, {
|
|
452
|
+
...outWarning, token, wallet: me,
|
|
453
|
+
hint: `sell at most sellableBeforeOut to keep the reward, or re-run with --confirm-out once the user accepts losing it`,
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return out(await poolTrade(ctx, {
|
|
460
|
+
side: sub, token, quote: sale.quote, me, slippage, buyTaxBps: Number(buyTaxBps), sellTaxBps: Number(sellTaxBps), handler,
|
|
461
|
+
amount: sub === 'sell' && flag('all') ? null : parseAmount(fail, 'amount', opt('amount')), all: sub === 'sell' && flag('all'),
|
|
462
|
+
swapBackGas: Number(sellTaxBps) > 0, payBnb: sub === 'buy' && poolPayBnb(ctx, sale.quote, flag), toBnb: flag('to-bnb'),
|
|
463
|
+
base: { kind: 'fair', pair: sale.pair, ...(outWarning && { ...outWarning, confirmedOut: true }) },
|
|
464
|
+
}, { rawOnly, summarize, revertOf }))
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (sub === 'claim') {
|
|
468
|
+
const token = tokenArg(opt, fail)
|
|
469
|
+
const sale = await saleOf(ctx, token)
|
|
470
|
+
if (!sale) return notFair(token)
|
|
471
|
+
const me = walletFor(ctx, opt('from'))
|
|
472
|
+
const vault = await ctx.read(ctx.pad, PAD_ABI, 'dividendVault')
|
|
473
|
+
const r = await dividendOf(ctx, vault, token, me, sale.quote, await poolPriceRaw(ctx, token, sale.pair))
|
|
474
|
+
const unsold = r.creditedUnsoldTokens && Number(r.creditedUnsoldTokens) > 0
|
|
475
|
+
if (r.claimableRaw === 0n) {
|
|
476
|
+
return fail('nothing to claim yet', {
|
|
477
|
+
...(unsold && { creditedUnsoldEstimate: r.creditedUnsoldEstimate, note: r.note }),
|
|
478
|
+
hint: `gang fair status --token ${token}${testnet ? ' --testnet' : ''}`,
|
|
479
|
+
})
|
|
480
|
+
}
|
|
481
|
+
const data = encodeFunctionData({ abi: VAULT_ABI, functionName: 'claim', args: [token] })
|
|
482
|
+
return out(step(ctx, { from: me, to: vault, data }, {
|
|
483
|
+
step: 'claim', chainId: Number(ctx.chainId), token, amount: r.claimable, quote: sale.quoteSymbol,
|
|
484
|
+
...(unsold && { stillCreditedUnsold: r.creditedUnsoldEstimate }),
|
|
485
|
+
}, rawOnly, summarize))
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (sub === 'tx') {
|
|
489
|
+
const hash = opt('hash')
|
|
490
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(hash ?? '')) fail('--hash must be a tx hash')
|
|
491
|
+
const r = await ctx.client.waitForTransactionReceipt({ hash, timeout: 120_000 })
|
|
492
|
+
const events = []
|
|
493
|
+
for (const log of r.logs) {
|
|
494
|
+
for (const abi of [PAD_ABI, VAULT_ABI]) {
|
|
495
|
+
try {
|
|
496
|
+
const ev = decodeEventLog({ abi, data: log.data, topics: log.topics })
|
|
497
|
+
events.push({ event: ev.eventName, ...Object.fromEntries(Object.entries(ev.args).map(([k, v]) => [k, typeof v === 'bigint' ? v.toString() : v])) })
|
|
498
|
+
break
|
|
499
|
+
} catch { /* other contract / event */ }
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return out({ success: true, chainId: Number(ctx.chainId), hash, status: r.status, block: Number(r.blockNumber), events, explorer: `${ctx.explorer}/tx/${hash}` })
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return fail(USAGE, { got: sub })
|
|
506
|
+
}
|