@gangfun/cli 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,15 +10,10 @@ Node ≥ 18. Also install Binance Agentic Wallet CLI (`baw`):
10
10
 
11
11
  ```bash
12
12
  npm i -g @binance/agentic-wallet
13
- npm i -g @gangfun/cli@0.2.1 --registry=https://npm.pkg.github.com
13
+ npm i -g @gangfun/cli@latest
14
14
  ```
15
15
 
16
- Package: [GitHub Packages `@gangfun/cli`](https://github.com/gangfun/cli/pkgs/npm/cli). Auth:
17
-
18
- ```ini
19
- @gangfun:registry=https://npm.pkg.github.com
20
- //npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN
21
- ```
16
+ Package: [`@gangfun/cli` on npm](https://www.npmjs.com/package/@gangfun/cli) (public registry, no auth). Upgrade with the same command.
22
17
 
23
18
  **Alternate** (public git tip):
24
19
 
@@ -44,12 +39,16 @@ npx skills add gangfun/gang-launchpad/skill
44
39
  ```bash
45
40
  gang preflight
46
41
  gang list
42
+ gang hot [--limit 10] [--track all|v2|soft] [--include-graduated] # 24h USD volume
43
+ gang new [--limit 10] [--max-age 2h]
44
+ gang near-grad [--min-pct 50] [--limit 10]
45
+ gang watch [<addr>] [--interval 10] [--events a,b] [--max-events N] [--timeout 30m] [--webhook URL]
47
46
  gang token --token <addr>
48
47
  gang quote --token <addr> --bnb <n> # Soft archive: BNB; V2: quote ERC-20 (LaunchpadV2.sol math)
49
- gang buy --token <addr> --bnb <n> [--slippage 1]
50
- gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1]
48
+ gang buy --token <addr> --bnb <n> [--slippage 1] [--note "<why>" [--conf 0-100]]
49
+ gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1] [--note … [--conf …]]
51
50
  gang upload-logo --file ./avatar.png # → https://gang.fun/api/logos/<id>.png
52
- gang create --name <n> --symbol <s> [--logo-uri URL | --logo ./avatar.png] [--taxed] [--pair-or-quote 0x…]
51
+ gang create --name <n> --symbol <s> [--logo-uri URL | --logo ./avatar.png] [--taxed] [--quote 0x… --ref-pool 0x…] [--note …]
53
52
  gang execute --request-id <id>
54
53
  gang tx --hash <0x..>
55
54
  ```
@@ -70,10 +69,37 @@ gang create --name "My Agent Coin" --symbol MAC --logo ./avatar.png
70
69
  gang execute --request-id …
71
70
  ```
72
71
 
73
- `--logo-uri ""` / omit logo → empty `logoURI` on-chain (allowed). `--taxed` requires `--pair-or-quote`. Untaxed default: `taxed=false`, `pairOrQuote=0x0` (WBNB).
72
+ `--logo-uri ""` / omit logo → empty `logoURI` on-chain (allowed). Default quote is WBNB; `--taxed` needs no pool. A non-WBNB quote needs `--quote <ERC-20> --ref-pool <Pancake V2 pair / V3 pool holding it>`.
73
+ The CLI dry-runs `create` before preview and names the contract error (`RefPoolRequired`, `ThinReferencePool`, …) if it would revert.
74
+ `--pair-or-quote` was removed: ≤0.3.0 encoded a 5-argument create that LaunchpadV2 doesn't have.
74
75
 
75
76
  Buy / sell / create: **preview → you confirm → execute**. `requestId` expires in ~5 minutes.
76
77
 
78
+ ### Discovery (0.3.0+)
79
+
80
+ `hot` / `new` / `near-grad` / `watch` read the gang.fun indexer (`GANG_API`) and cover the V2 and Soft tracks. Each row has
81
+ `quote` (the unit to pass to `--bnb`), `progressPct`, `remaining`/`remainingUsd`, `volume24hUsd`, `holders`, `ageMin` and a `next` command.
82
+ Graduated tokens are hidden unless `--include-graduated` is passed.
83
+
84
+ `gang watch` polls and prints NDJSON. The minimum interval is 3s with `<addr>` and 10s for the whole market. With `<addr>` it emits `trade` / `progress` (25/50/75/90%) / `graduated`.
85
+ Without it, it emits `new_token` / `near_grad` (crosses `--min-pct`) / `graduated`. `--max-events 1` blocks until the next event and
86
+ `--webhook` POSTs each event. Details: [skill/references/discover.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/discover.md).
87
+
88
+ ```bash
89
+ gang watch --events new_token,near_grad --max-events 1 --timeout 30m
90
+ ```
91
+
92
+ ### Agent notes (`--note`)
93
+
94
+ `--note "<why>" [--conf 0-100]` on buy / sell / create attaches a **public** reason, shown on gang.fun next to the trade.
95
+ 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.
96
+ 1–280 characters, no links. `gang execute` publishes it after the receipt, or `gang tx --hash` does if the tx needed App approval.
97
+ A failed publish never affects the trade. Details: [skill/references/notes.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/notes.md).
98
+
99
+ ```bash
100
+ gang buy --token 0x… --bnb 0.1 --note "volume doubled in 1h, buying the breakout" --conf 70
101
+ ```
102
+
77
103
  ## Quote path
78
104
 
79
105
  - **Soft archive tokens:** on-chain `quoteBuy` / `quoteSell`.
@@ -94,7 +120,9 @@ Buy / sell / create: **preview → you confirm → execute**. `requestId` expire
94
120
  | `GANG_LAUNCHPAD` | Create target (default LaunchpadV2 `0x1B8F3f69…966E`) |
95
121
  | `GANG_RPC_URL` | BSC HTTP RPC |
96
122
  | `GANG_LOGO_API` | Logo upload endpoint (default `https://gang.fun/api/logos`) |
123
+ | `GANG_API` | Indexer API for discovery and note publishing (default `https://gang.fun/api`) |
124
+ | `GANG_NOTES_DIR` | Local queue of unpublished notes, one file per note (default `~/.gang/notes`) |
97
125
 
98
126
  ## Version
99
127
 
100
- `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)
128
+ `0.3.1` · repo [gangfun/cli](https://github.com/gangfun/cli) · published to [npm](https://www.npmjs.com/package/@gangfun/cli) on push to `main`
package/README.zh-CN.md CHANGED
@@ -10,10 +10,10 @@ Node ≥ 18。另需 `baw`:
10
10
 
11
11
  ```bash
12
12
  npm i -g @binance/agentic-wallet
13
- npm i -g @gangfun/cli@0.2.1 --registry=https://npm.pkg.github.com
13
+ npm i -g @gangfun/cli@latest
14
14
  ```
15
15
 
16
- 或:`npm i -g github:gangfun/cli`
16
+ npm 公共源,无需鉴权;升级用同一条命令。或(git 最新):`npm i -g github:gangfun/cli`
17
17
 
18
18
  Skill:`npx skills add gangfun/gang-launchpad/skill`
19
19
 
@@ -29,12 +29,16 @@ Skill:`npx skills add gangfun/gang-launchpad/skill`
29
29
  ```bash
30
30
  gang preflight
31
31
  gang list
32
+ gang hot [--limit 10] [--track all|v2|soft] [--include-graduated] # 按 24h USD 成交量
33
+ gang new [--limit 10] [--max-age 2h]
34
+ gang near-grad [--min-pct 50] [--limit 10]
35
+ gang watch [<addr>] [--interval 10] [--events a,b] [--max-events N] [--timeout 30m] [--webhook URL]
32
36
  gang token --token <addr>
33
37
  gang quote --token <addr> --bnb <n>
34
- gang buy --token <addr> --bnb <n> [--slippage 1]
35
- gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1]
38
+ gang buy --token <addr> --bnb <n> [--slippage 1] [--note "<理由>" [--conf 0-100]]
39
+ gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1] [--note … [--conf …]]
36
40
  gang upload-logo --file ./avatar.png
37
- gang create --name <n> --symbol <s> [--logo-uri URL | --logo ./avatar.png] [--taxed] [--pair-or-quote 0x…]
41
+ gang create --name <n> --symbol <s> [--logo-uri URL | --logo ./avatar.png] [--taxed] [--quote 0x… --ref-pool 0x…] [--note …]
38
42
  gang execute --request-id <id>
39
43
  gang tx --hash <0x..>
40
44
  ```
@@ -53,6 +57,29 @@ gang create --name "My Agent Coin" --symbol MAC --logo ./avatar.png
53
57
  gang execute --request-id …
54
58
  ```
55
59
 
60
+ 默认计价是 WBNB;`--taxed` 不需要池子。用其他代币计价时,加 `--quote <ERC-20> --ref-pool <持有该代币的 Pancake V2 pair / V3 池>`。
61
+ preview 之前 CLI 会先模拟一次 `create`,如果会回滚,就直接报出合约的错误名(`RefPoolRequired`、`ThinReferencePool` 等)。
62
+ `--pair-or-quote` 已移除:0.3.0 及之前的版本编码的是 LaunchpadV2 没有的 5 参数 create。
63
+
64
+ ### 发现(0.3.0+)
65
+
66
+ `hot` / `new` / `near-grad` / `watch` 读 gang.fun indexer(`GANG_API`),同时覆盖 V2 与 Soft。每行带 `quote`(`--bnb` 的单位)、
67
+ `progressPct`、`remaining`/`remainingUsd`、`volume24hUsd`、`holders`、`ageMin` 和 `next` 命令;已毕业默认隐藏(`--include-graduated` 显示)。
68
+
69
+ `gang watch` 轮询并输出 NDJSON(最短间隔:单币 3s,全市场 10s):带 `<addr>` 推 `trade` / `progress`(25/50/75/90%)/ `graduated`;不带则推 `new_token` / `near_grad`(越过 `--min-pct`)/ `graduated`。
70
+ `--max-events 1` = 阻塞到下一个事件;`--webhook` 把每个事件 POST 出去。详见 [skill/references/discover.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/discover.md)。
71
+
72
+ ### Agent 想法(`--note`)
73
+
74
+ 在 buy / sell / create 后面加 `--note "<理由>" [--conf 0-100]`,就能给这笔交易附一句理由。附言**会公开**,显示在 gang.fun 上这笔交易旁边。
75
+ 附言的哈希会追加到交易 calldata 末尾(37 字节,合约会忽略),因此可以证明它写在交易之前,事后也改不了。
76
+ 附言限 1–280 字,不能含链接。`gang execute` 在拿到回执后自动公开;如果交易需要 App 确认,就在确认后用 `gang tx --hash` 补发。
77
+ 公开失败不影响交易本身。详见 [skill/references/notes.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/notes.md)。
78
+
79
+ ```bash
80
+ gang buy --token 0x… --bnb 0.1 --note "1 小时量能翻倍,买突破" --conf 70
81
+ ```
82
+
56
83
  ## Quote 路径
57
84
 
58
85
  - Soft 存量:链上 `quoteBuy` / `quoteSell`。
@@ -71,7 +98,9 @@ gang execute --request-id …
71
98
  | `GANG_LAUNCHPAD` | 发币目标(默认 V2) |
72
99
  | `GANG_RPC_URL` | BSC RPC |
73
100
  | `GANG_LOGO_API` | Logo 上传 API(默认 `https://gang.fun/api/logos`) |
101
+ | `GANG_API` | indexer API,发现命令和公开附言都用它(默认 `https://gang.fun/api`) |
102
+ | `GANG_NOTES_DIR` | 未公开附言的本地队列,每条一个文件(默认 `~/.gang/notes`) |
74
103
 
75
104
  ## 版本
76
105
 
77
- `0.2.1` · [gangfun/cli](https://github.com/gangfun/cli)
106
+ `0.3.1` · [gangfun/cli](https://github.com/gangfun/cli) · push 到 `main` 自动发布到 [npm](https://www.npmjs.com/package/@gangfun/cli)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gangfun/cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Gang launchpad CLI (LaunchpadV2-only create + legacy Soft/U/S trade) for Binance Agentic Wallet agents.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/abi.js CHANGED
@@ -21,7 +21,9 @@ export const SOFT_ABI = [
21
21
 
22
22
  /** LaunchpadV2 (+ U/S-shaped quote pads): create with logoURI; buy/sell take quote ERC-20. */
23
23
  export const V2_ABI = [
24
- { 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' }] },
24
+ // quote 0x0 = WBNB; refPool = Pancake V2 pair / V3 pool holding `quote` (ignored for WBNB or a QuoteRegistry override)
25
+ { 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' }] },
26
+ { type: 'function', name: 'graduationAmountOf', stateMutability: 'view', inputs: [{ name: 'quote', type: 'address' }, { name: 'refPool', type: 'address' }], outputs: [{ type: 'uint256' }] },
25
27
  { type: 'function', name: 'buy', stateMutability: 'nonpayable', inputs: [{ name: 'token', type: 'address' }, { name: 'quoteIn', type: 'uint256' }, { name: 'minTokensOut', type: 'uint256' }], outputs: [{ type: 'uint256' }] },
26
28
  { type: 'function', name: 'sell', stateMutability: 'nonpayable', inputs: [{ name: 'token', type: 'address' }, { name: 'tokensIn', type: 'uint256' }, { name: 'minQuoteOut', type: 'uint256' }], outputs: [{ type: 'uint256' }] },
27
29
  { type: 'function', name: 'curves', stateMutability: 'view', inputs: [{ name: 'token', type: 'address' }], outputs: [
@@ -36,6 +38,8 @@ export const V2_ABI = [
36
38
  { type: 'function', name: 'MAX_PER_WALLET', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
37
39
  { type: 'function', name: 'TARGET_GRAD_BNB', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
38
40
  { type: 'function', name: 'logoURIOf', stateMutability: 'view', inputs: [{ type: 'address' }], outputs: [{ type: 'string' }] },
41
+ // custom errors (LaunchpadV2 + PoolCheck) so a reverted dry run names the reason
42
+ ...['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: [] })),
39
43
  { type: 'event', name: 'Created', inputs: [
40
44
  { name: 'token', type: 'address', indexed: true }, { name: 'creator', type: 'address', indexed: true },
41
45
  { name: 'name', type: 'string', indexed: false }, { name: 'symbol', type: 'string', indexed: false },
package/src/chain.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createPublicClient, http, formatEther, parseEther, encodeFunctionData, decodeEventLog, zeroAddress } from 'viem'
1
+ import { createPublicClient, http, formatEther, formatUnits, parseEther, encodeFunctionData, decodeEventLog, decodeErrorResult, isAddress, zeroAddress } from 'viem'
2
2
  import { bsc } from 'viem/chains'
3
3
  import { SOFT_ABI, V2_ABI, ERC20_ABI } from './abi.js'
4
4
 
@@ -21,7 +21,7 @@ export const CONFIG = {
21
21
  export const client = createPublicClient({ chain: bsc, transport: http(CONFIG.rpc) })
22
22
  export const fmt = (wei) => formatEther(wei)
23
23
  export const fmtTok = (units) => Number(formatEther(units)).toLocaleString('en-US', { maximumFractionDigits: 2 })
24
- export { parseEther, encodeFunctionData, zeroAddress }
24
+ export { parseEther, encodeFunctionData, isAddress, zeroAddress }
25
25
 
26
26
  const erc = (address, functionName, args = []) => client.readContract({ address, abi: ERC20_ABI, functionName, args })
27
27
  const read = (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args })
@@ -57,7 +57,7 @@ export async function resolvePad(token) {
57
57
  }
58
58
  throw new Error(
59
59
  `not a Gang token on known pads (V2/Soft/U/S): ${token}. ` +
60
- `Upgrade: npm i -g @gangfun/cli@0.2.1 --registry=https://npm.pkg.github.com OR npm i -g github:gangfun/cli`,
60
+ `Upgrade: npm i -g @gangfun/cli@latest OR npm i -g github:gangfun/cli`,
61
61
  )
62
62
  }
63
63
 
@@ -254,8 +254,49 @@ export const calldata = {
254
254
  v2Buy: (token, quoteIn, minTokensOut) => encodeFunctionData({ abi: V2_ABI, functionName: 'buy', args: [token, quoteIn, minTokensOut] }),
255
255
  v2Sell: (token, tokensIn, minQuoteOut) => encodeFunctionData({ abi: V2_ABI, functionName: 'sell', args: [token, tokensIn, minQuoteOut] }),
256
256
  approve: (spender, amount) => encodeFunctionData({ abi: ERC20_ABI, functionName: 'approve', args: [spender, amount] }),
257
- createV2: (name, symbol, logoURI, taxed, pairOrQuote) =>
258
- encodeFunctionData({ abi: V2_ABI, functionName: 'create', args: [name, symbol, logoURI, taxed, pairOrQuote] }),
257
+ createV2: (name, symbol, logoURI, taxed, quote, refPool) =>
258
+ encodeFunctionData({ abi: V2_ABI, functionName: 'create', args: [name, symbol, logoURI, taxed, quote, refPool] }),
259
+ }
260
+
261
+ /**
262
+ * Dry-run a create from the agent wallet (eth_call) before baw preview, so a bad quote / refPool / gate fails
263
+ * here with the contract's error name instead of an opaque preview rejection.
264
+ * → { ok: true, gradAmt, tokenIfNext } | { ok: false, reverted: true, error: <ContractError> } | { ok: false, reverted: false, error }
265
+ * `reverted: false` means the RPC failed, not the create: retrying with the same flags is fine.
266
+ */
267
+ export async function preflightCreate(from, data, quote, refPool) {
268
+ let gradAmt, r
269
+ try {
270
+ gradAmt = await read(ADDR.v2, V2_ABI, 'graduationAmountOf', [quote, refPool])
271
+ r = await client.call({ account: from, to: ADDR.v2, data })
272
+ } catch (e) {
273
+ return { ok: false, ...callFailure(e) }
274
+ }
275
+ // CREATE address: only right if no other create lands first
276
+ const tokenIfNext = r.data && r.data.length >= 66 ? `0x${r.data.slice(-40)}` : null
277
+ return { ok: true, gradAmt: await fmtQuote(quote, gradAmt), tokenIfNext }
278
+ }
279
+
280
+ /** Quote units with the quote's decimals; raw integer if the token has no readable decimals(). */
281
+ async function fmtQuote(quote, amount) {
282
+ if (quote === zeroAddress || quote.toLowerCase() === ADDR.wbnb.toLowerCase()) return formatUnits(amount, 18)
283
+ try { return formatUnits(amount, Number(await erc(quote, 'decimals'))) } catch { return `${amount} (raw units)` }
284
+ }
285
+
286
+ function callFailure(e) {
287
+ const raw = e?.walk?.((x) => typeof x?.data === 'string' && x.data.startsWith('0x'))?.data
288
+ if (raw && raw.length >= 10) {
289
+ try { return { reverted: true, error: decodeErrorResult({ abi: V2_ABI, data: raw }).errorName } } catch { /* not one of ours */ }
290
+ }
291
+ const named = e?.walk?.((x) => x?.data?.errorName)?.data?.errorName
292
+ if (named) return { reverted: true, error: named }
293
+ // empty revert data: viem only names it ExecutionRevertedError when the node's text matches, so also accept
294
+ // JSON-RPC code 3 (the standard execution-error code) or any "revert" in the chain; everything else is the RPC
295
+ const revert = e?.walk?.((x) => ['ExecutionRevertedError', 'ContractFunctionRevertedError'].includes(x?.name) ||
296
+ x?.code === 3 || /revert/i.test(x?.details || x?.shortMessage || ''))
297
+ // a revert carries the node's own words; the "RPC Request failed." wrapper text would read like a network error
298
+ if (revert) return { reverted: true, error: revert.details || revert.shortMessage || 'execution reverted' }
299
+ return { reverted: false, error: [e?.shortMessage, e?.details].filter(Boolean).join(' — ') || e?.message || String(e) }
259
300
  }
260
301
 
261
302
  export async function decodeTx(hash) {
@@ -0,0 +1,266 @@
1
+ /** Discovery for agents: ranked lists + a polling event stream, read from the gang.fun indexer API. */
2
+ export const API = (process.env.GANG_API || 'https://gang.fun/api').replace(/\/+$/, '')
3
+
4
+ const SOFT_GRAD_BNB = 20
5
+ const MILESTONES = [25, 50, 75, 90]
6
+ const MARKET_PAGES = 10 // × 200 rows per track
7
+ const TRADE_PAGE = 50
8
+ const TRADE_PAGES = 4
9
+ const SEEN_CAP = 2000
10
+ const num = (v) => (v == null || v === '' ? 0 : Number(v))
11
+ const usd = (v) => (Number.isFinite(v) ? Math.round(v * 100) / 100 : null)
12
+ const now = () => Math.floor(Date.now() / 1000)
13
+
14
+ /** `stop` (optional AbortSignal) cancels an in-flight request, e.g. when `gang watch --timeout` expires. */
15
+ async function get(path, stop) {
16
+ // own controller instead of AbortSignal.any (Node ≥20.3); listener removed after each request so a
17
+ // long-lived `stop` signal doesn't pile up one listener per poll
18
+ const ctl = new AbortController()
19
+ const timer = setTimeout(() => ctl.abort(new Error('timed out after 15s')), 15_000)
20
+ const onStop = () => ctl.abort(new Error('stopped'))
21
+ if (stop?.aborted) onStop()
22
+ else stop?.addEventListener('abort', onStop, { once: true })
23
+ try {
24
+ let r
25
+ try {
26
+ r = await fetch(`${API}${path}`, { signal: ctl.signal, headers: { accept: 'application/json' } })
27
+ } catch (e) {
28
+ if (stop?.aborted) throw e
29
+ throw new Error(`gang API unreachable (${API}${path}): ${ctl.signal.reason?.message || e.message}. On-chain fallback without volume: \`gang list\``)
30
+ }
31
+ if (r.status === 404) return null
32
+ if (!r.ok) throw new Error(`gang API ${r.status} on ${path}`)
33
+ try {
34
+ return await r.json()
35
+ } catch (e) {
36
+ // body read can also hit the 15s timeout or a truncated/non-JSON body; keep the path in the message
37
+ if (stop?.aborted) throw e
38
+ throw new Error(`gang API bad response on ${path}: ${ctl.signal.reason?.message || e.message}`)
39
+ }
40
+ } finally {
41
+ clearTimeout(timer)
42
+ stop?.removeEventListener('abort', onStop)
43
+ }
44
+ }
45
+
46
+ /** "90" | "45s" | "30m" | "2h" | "1d" → seconds */
47
+ export function parseDuration(raw, name) {
48
+ const m = String(raw ?? '').trim().match(/^(\d+(?:\.\d+)?)([smhd]?)$/i)
49
+ if (!m) throw new Error(`--${name} must look like 90, 45s, 30m, 2h or 1d (got ${raw})`)
50
+ return Math.round(Number(m[1]) * { '': 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase()])
51
+ }
52
+
53
+ /**
54
+ * One shape for both API tracks. `v2` = quote-ERC20 curves (/stocks/tokens, LaunchpadV2 family);
55
+ * `soft` = legacy BNB curve (/tokens, 20 BNB graduation). Amounts are human units of `quote`.
56
+ */
57
+ export function normalize(r) {
58
+ const v2 = r.track === 'stocks'
59
+ const quote = v2 ? (r.quoteSymbol || '?') : 'BNB'
60
+ const raised = v2 ? num(r.raisedQuote) : num(r.raisedBnb)
61
+ const target = v2 ? num(r.gradAmtQuote) : SOFT_GRAD_BNB
62
+ const remaining = r.graduated ? 0 : (v2 ? num(r.graduationRemainingQuote) : Math.max(0, target - raised))
63
+ const quoteUsd = v2 ? num(r.quoteUsd) : num(r.bnbUsd)
64
+ return {
65
+ address: r.address, symbol: r.symbol, name: r.name, track: v2 ? 'v2' : 'soft',
66
+ quote, quoteAddress: v2 ? r.quoteAddress : null, taxed: v2 ? !!r.taxed : false,
67
+ graduated: !!r.graduated, pair: r.pair || null,
68
+ progressPct: num(r.progressPct), raised, graduationTarget: target, remaining,
69
+ remainingUsd: quoteUsd ? usd(remaining * quoteUsd) : null,
70
+ priceUsd: r.priceUsd ?? null, fdvUsd: usd(num(r.fdvUsd)),
71
+ volume24hUsd: usd(num(r.volume24hUsd)), trades24h: num(r.trades24h), trades: num(r.trades), holders: num(r.holders),
72
+ createdAt: r.createdAt, ageMin: r.createdAt ? Math.floor((now() - r.createdAt) / 60) : null,
73
+ lastTradeAt: r.lastTradeAt || null, creatorHandle: r.creatorHandle || null, logoUrl: r.logoUrl || null,
74
+ }
75
+ }
76
+
77
+ async function pages(path, stop) {
78
+ const rows = []
79
+ for (let offset = 0, i = 0; i < MARKET_PAGES; i++) {
80
+ const sep = path.includes('?') ? '&' : '?'
81
+ const page = await get(`${path}${sep}limit=200&offset=${offset}`, stop)
82
+ // list routes always exist: a 404 is an outage, never "no tokens"
83
+ if (!page) throw new Error(`gang API 404 on ${path}`)
84
+ rows.push(...(page.items || []))
85
+ if (!page.hasMore || page.nextOffset == null) return { rows, truncated: false }
86
+ offset = page.nextOffset
87
+ }
88
+ return { rows, truncated: true }
89
+ }
90
+
91
+ /** All indexed tokens, normalized and de-duplicated. track: all | v2 | soft */
92
+ export async function fetchMarket(track = 'all', stop) {
93
+ if (!['all', 'v2', 'soft'].includes(track)) throw new Error(`--track must be all, v2 or soft (got ${track})`)
94
+ const none = { rows: [], truncated: false }
95
+ const [v2, soft] = await Promise.all([
96
+ track === 'soft' ? none : pages('/stocks/tokens', stop),
97
+ track === 'v2' ? none : pages('/tokens?sort=new', stop),
98
+ ])
99
+ const seen = new Map()
100
+ for (const r of [...v2.rows, ...soft.rows]) {
101
+ const t = normalize(r)
102
+ if (!seen.has(t.address)) seen.set(t.address, t)
103
+ }
104
+ return { tokens: [...seen.values()], truncated: v2.truncated || soft.truncated }
105
+ }
106
+
107
+ const hint = (t) => `gang quote --token ${t.address} --bnb <amount in ${t.quote}>`
108
+ const withHint = (t) => ({ ...t, next: t.graduated ? `trade on PancakeSwap (pair ${t.pair})` : hint(t) })
109
+
110
+ /** hot = 24h USD volume on the curve; new = newest first; near-grad = closest to graduation. */
111
+ export function rank(tokens, sort, { limit = 10, minPct = 50, maxAgeSec = null, includeGraduated = false } = {}) {
112
+ let list = includeGraduated ? tokens : tokens.filter((t) => !t.graduated)
113
+ if (sort === 'hot') {
114
+ // graduated rows carry DEX volume with trades24h = 0, so volume alone also qualifies
115
+ list = list.filter((t) => t.trades24h > 0 || t.volume24hUsd > 0)
116
+ .sort((a, b) => (b.volume24hUsd - a.volume24hUsd) || (b.trades24h - a.trades24h))
117
+ } else if (sort === 'new') {
118
+ const t0 = now()
119
+ if (maxAgeSec != null) list = list.filter((t) => t0 - t.createdAt <= maxAgeSec)
120
+ list.sort((a, b) => b.createdAt - a.createdAt)
121
+ } else if (sort === 'near-grad') {
122
+ list = list.filter((t) => !t.graduated && t.progressPct >= minPct)
123
+ .sort((a, b) => (b.progressPct - a.progressPct) || ((a.remainingUsd ?? Infinity) - (b.remainingUsd ?? Infinity)))
124
+ }
125
+ return list.slice(0, limit).map(withHint)
126
+ }
127
+
128
+ async function tokenDetail(addr, stop) {
129
+ const r = await get(`/tokens/${addr.toLowerCase()}`, stop)
130
+ if (!r) throw new Error(`token not indexed by ${API}: ${addr}. On-chain view: \`gang token --token ${addr}\``)
131
+ return normalize(r)
132
+ }
133
+
134
+ /** One page of trades, newest first. */
135
+ async function tradePage(t, offset = 0, stop) {
136
+ const base = t.track === 'v2' ? '/stocks/tokens' : '/tokens'
137
+ const page = await get(`${base}/${t.address}/trades?limit=${TRADE_PAGE}&offset=${offset}`, stop)
138
+ const items = (page?.items || []).map((x) => ({
139
+ side: x.side, trader: x.trader, traderHandle: x.traderHandle || null,
140
+ amount: num(x.quoteIn ?? x.bnb), quote: t.quote, tokens: num(x.tokens),
141
+ price: num(x.price), at: x.ts, tx: x.tx,
142
+ }))
143
+ return { items, next: page?.hasMore && page.nextOffset != null ? page.nextOffset : null }
144
+ }
145
+
146
+ const tradeKey = (x) => `${x.tx}:${x.side}:${x.trader}:${x.tokens}`
147
+
148
+ /**
149
+ * Trades not in `seen`, oldest first. Pages back until a page contains a known trade; `gap` = gave up
150
+ * after TRADE_PAGES pages of only-unseen trades, so older ones in between were skipped.
151
+ */
152
+ async function freshTrades(t, seen, stop) {
153
+ const fresh = []
154
+ const local = new Set() // offsets shift when trades land mid-pagination → same row on two pages
155
+ for (let offset = 0, i = 0; i < TRADE_PAGES; i++) {
156
+ const { items, next } = await tradePage(t, offset, stop)
157
+ let hit = false
158
+ for (const x of items) {
159
+ const k = tradeKey(x)
160
+ if (seen.has(k)) hit = true
161
+ else if (!local.has(k)) { local.add(k); fresh.push(x) }
162
+ }
163
+ if (hit || next == null) return { fresh: fresh.reverse(), gap: false }
164
+ offset = next
165
+ }
166
+ return { fresh: fresh.reverse(), gap: true }
167
+ }
168
+
169
+ const crossed = (prev, cur, levels) => levels.filter((m) => prev < m && cur >= m)
170
+ const brief = (t) => ({
171
+ token: t.address, symbol: t.symbol, track: t.track, quote: t.quote, progressPct: t.progressPct,
172
+ remaining: t.remaining, remainingUsd: t.remainingUsd, volume24hUsd: t.volume24hUsd, holders: t.holders,
173
+ })
174
+
175
+ /**
176
+ * Poll the API and call emit(event) for each change. Token mode: trade / progress / graduated.
177
+ * Market mode: new_token / near_grad (crossing minPct upward) / graduated.
178
+ * The first tick only snapshots state; current matches come from `gang near-grad` / `gang new`.
179
+ * `stop` (AbortSignal) cancels in-flight requests; errors after shouldStop() flips are dropped, not emitted.
180
+ */
181
+ export async function watch({ token, intervalSec = 10, minPct = 50, track = 'all', events = null, emit, shouldStop, stop }) {
182
+ const want = (e) => !events || events.has(e)
183
+ // wakes early once shouldStop() flips (e.g. --timeout) instead of overshooting a full interval
184
+ const sleep = async (s) => {
185
+ const end = Date.now() + s * 1000
186
+ while (Date.now() < end && !shouldStop()) await new Promise((r) => setTimeout(r, Math.min(1000, end - Date.now())))
187
+ }
188
+
189
+ if (token) {
190
+ // graduation always ends a token watch; when --events filters it out, end with a meta line instead
191
+ const finish = (t) => (want('graduated')
192
+ ? emit({ event: 'graduated', ...brief(t), pair: t.pair })
193
+ : emit({ event: 'ended', reason: 'graduated', token: t.address, pair: t.pair }, { meta: true }))
194
+ let t = await tokenDetail(token, stop)
195
+ const seen = new Set((await tradePage(t, 0, stop)).items.map(tradeKey))
196
+ const remember = (k) => {
197
+ seen.add(k)
198
+ if (seen.size > SEEN_CAP) seen.delete(seen.values().next().value) // Set keeps insertion order → drops oldest
199
+ }
200
+ emit({ event: 'watching', mode: 'token', ...brief(t), graduated: t.graduated, intervalSec }, { meta: true })
201
+ if (t.graduated) return finish(t)
202
+ for (;;) {
203
+ await sleep(intervalSec)
204
+ if (shouldStop()) return
205
+ try {
206
+ const cur = await tokenDetail(token, stop)
207
+ const { fresh, gap } = await freshTrades(cur, seen, stop)
208
+ // advance state only after both reads succeed, so a failed tick can't swallow a prev → cur transition
209
+ const prev = t
210
+ t = cur
211
+ if (gap) emit({ event: 'error', error: `more than ${TRADE_PAGE * TRADE_PAGES} trades since last poll; older ones skipped` }, { meta: true })
212
+ for (const x of fresh) {
213
+ remember(tradeKey(x))
214
+ if (want('trade')) emit({ event: 'trade', token: t.address, symbol: t.symbol, ...x, progressPct: t.progressPct })
215
+ if (shouldStop()) return
216
+ }
217
+ for (const m of crossed(prev.progressPct, t.progressPct, MILESTONES)) {
218
+ if (want('progress')) emit({ event: 'progress', milestone: m, ...brief(t), next: hint(t) })
219
+ if (shouldStop()) return
220
+ }
221
+ if (!prev.graduated && t.graduated) return finish(t)
222
+ } catch (e) {
223
+ if (shouldStop()) return
224
+ emit({ event: 'error', error: e.message }, { meta: true })
225
+ }
226
+ if (shouldStop()) return
227
+ }
228
+ }
229
+
230
+ const first = await fetchMarket(track, stop)
231
+ // only grows: a token missing from one poll must not come back as new_token
232
+ const known = new Map(first.tokens.map((t) => [t.address, t]))
233
+ emit({ event: 'watching', mode: 'market', track, tokens: known.size, minPct, intervalSec }, { meta: true })
234
+ let warned = false
235
+ const warnTruncated = (truncated) => {
236
+ if (!truncated || warned) return
237
+ warned = true
238
+ emit({ event: 'error', error: `market list capped at ${MARKET_PAGES * 200} tokens per track; tokens beyond the cap are not watched` }, { meta: true })
239
+ }
240
+ warnTruncated(first.truncated)
241
+ for (;;) {
242
+ await sleep(intervalSec)
243
+ if (shouldStop()) return
244
+ try {
245
+ const { tokens, truncated } = await fetchMarket(track, stop)
246
+ warnTruncated(truncated)
247
+ for (const t of tokens) {
248
+ const prev = known.get(t.address)
249
+ known.set(t.address, t)
250
+ const out = []
251
+ if (!prev) out.push({ event: 'new_token', ...withHint(t) })
252
+ else if (!prev.graduated && t.graduated) out.push({ event: 'graduated', ...brief(t), pair: t.pair })
253
+ else if (!t.graduated && prev.progressPct < minPct && t.progressPct >= minPct) {
254
+ out.push({ event: 'near_grad', ...brief(t), next: hint(t) })
255
+ }
256
+ for (const ev of out) {
257
+ if (want(ev.event)) emit(ev)
258
+ if (shouldStop()) return
259
+ }
260
+ }
261
+ } catch (e) {
262
+ if (shouldStop()) return
263
+ emit({ event: 'error', error: e.message }, { meta: true })
264
+ }
265
+ }
266
+ }
package/src/index.js CHANGED
@@ -1,17 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  CONFIG, ADDR, listTokens, tokenInfo, quoteBuy, quoteSell, isAgent, balanceOf, allowance,
4
- calldata, decodeTx, parseEther, fmt, fmtTok, resolvePad, assertCreateTarget, zeroAddress,
4
+ calldata, decodeTx, parseEther, fmt, fmtTok, resolvePad, assertCreateTarget, preflightCreate, isAddress, zeroAddress, client,
5
5
  } from './chain.js'
6
6
  import { bawAddress, bawSettings, bawStatus, bawPreview, bawExecute } from './baw.js'
7
7
  import { uploadLogoFile, LOGO_UI } from './logo.js'
8
+ import { API, fetchMarket, rank, watch, parseDuration } from './discover.js'
9
+ import { makeNote, withNote, queueNote, findByRequestId, findByHashes, hashesIn, publishNote, hasUnpublished } from './notes.js'
8
10
 
9
11
  const args = process.argv.slice(2)
10
12
  const cmd = args[0]
11
13
  const opt = (name, def) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : def }
12
14
  const flag = (name) => args.includes(`--${name}`)
13
15
  const out = (o) => console.log(JSON.stringify(o, (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2))
14
- const fail = (msg, extra = {}) => { out({ success: false, error: msg, ...extra }); process.exit(1) }
16
+ // `gang watch` speaks NDJSON, so its failures are one line too (with event/fatal for stream consumers)
17
+ const fail = (msg, extra = {}) => {
18
+ if (cmd === 'watch') console.log(JSON.stringify({ event: 'error', fatal: true, success: false, error: msg, ...extra }))
19
+ else out({ success: false, error: msg, ...extra })
20
+ process.exit(1)
21
+ }
15
22
 
16
23
  const parseSlippage = (raw) => {
17
24
  const s = Number(raw)
@@ -19,22 +26,60 @@ const parseSlippage = (raw) => {
19
26
  return s
20
27
  }
21
28
 
29
+ const parseCount = (name, raw, max) => {
30
+ const n = Number(raw)
31
+ if (!Number.isInteger(n) || n < 1 || n > max) fail(`--${name} must be an integer between 1 and ${max}`, { got: raw })
32
+ return n
33
+ }
34
+ const parsePct = (raw) => {
35
+ const n = Number(raw)
36
+ if (!Number.isFinite(n) || n < 0 || n > 100) fail('--min-pct must be a number between 0 and 100', { got: raw })
37
+ return n
38
+ }
39
+ const WATCH_EVENTS = ['trade', 'progress', 'graduated', 'new_token', 'near_grad']
40
+
41
+ // --note is validated before any RPC / preview, so a bad note never reaches calldata
42
+ const parseNote = () => {
43
+ if (!flag('note')) {
44
+ if (flag('conf')) fail('--conf only applies together with --note')
45
+ return null
46
+ }
47
+ const raw = opt('note')
48
+ // a value starting with "--" is almost always the next flag (`--note --conf 80`), so it's refused rather than published
49
+ if (raw == null || raw.startsWith('--')) {
50
+ fail('--note needs text right after it (and the text can\'t start with "--"), e.g. --note "volume doubled in 1h, buying the breakout"')
51
+ }
52
+ if (flag('conf') && opt('conf') == null) fail('--conf needs a value 0–100')
53
+ try { return makeNote(raw, opt('conf')) } catch (e) { fail(e.message) }
54
+ }
55
+
22
56
  const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet) · LaunchpadV2 create
23
57
 
24
58
  gang preflight check sign-in, Developer Mode, tradeAllTokens, Agent ID
25
59
  gang list tokens across V2 / Soft / U / S
60
+ gang hot [--limit 10] [--track all|v2|soft] [--include-graduated]
61
+ curve tokens ranked by 24h USD volume
62
+ gang new [--limit 10] [--max-age 2h] [--track …] newest curve tokens first
63
+ gang near-grad [--min-pct 50] [--limit 10] [--track …] closest to graduation
64
+ gang watch [<addr>] [--interval 10] [--min-pct 50] [--events a,b] [--max-events N] [--timeout 30m] [--webhook URL]
65
+ NDJSON stream (min interval: 3s token, 10s market). <addr>: trade / progress / graduated.
66
+ no addr: new_token / near_grad / graduated across the market
26
67
  gang token --token <addr> one token (auto-resolves pad)
27
68
  gang quote --token <addr> --bnb <n> Soft: BNB quote; V2/U/S: quote-token amount (same flag)
28
- gang buy --token <addr> --bnb <n> [--slippage 1] Soft: payable BNB; V2: approve quote then buy
29
- gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1]
69
+ gang buy --token <addr> --bnb <n> [--slippage 1] [--note "<why>" [--conf 0-100]]
70
+ Soft: payable BNB; V2: approve quote then buy
71
+ gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1] [--note … [--conf …]]
30
72
  gang upload-logo --file <path> POST image → https://gang.fun/api/logos → logoUrl
31
- gang create --name <n> --symbol <s> [--logo-uri URL | --logo <file>] [--taxed] [--pair-or-quote 0x…]
73
+ gang create --name <n> --symbol <s> [--logo-uri URL | --logo <file>] [--taxed] [--quote 0x… --ref-pool 0x…] [--note … [--conf …]]
32
74
  LaunchpadV2 ONLY (${ADDR.v2}); Soft/U/S create refused
33
75
  gang execute --request-id <id> broadcast a previewed call
34
- gang tx --hash <0x..> wait for receipt and decode Gang events
76
+ gang tx --hash <0x..> wait for receipt and decode Gang events (publishes a queued --note)
77
+
78
+ --note is PUBLIC: shown on gang.fun next to the trade. Its hash goes into the tx calldata, so it is provably written
79
+ before the trade and can't be edited later. 1–280 chars, no links. Published by \`gang execute\` (or \`gang tx\`) within 24h.
35
80
 
36
81
  Logo UI (browser): ${LOGO_UI}
37
- env: GANG_LAUNCHPAD (default V2 ${ADDR.v2}), GANG_RPC_URL, GANG_LOGO_API`
82
+ env: GANG_LAUNCHPAD (default V2 ${ADDR.v2}), GANG_RPC_URL, GANG_LOGO_API, GANG_API (default ${API}), GANG_NOTES_DIR (default ~/.gang/notes)`
38
83
 
39
84
  function summarizePreview(p, extra = {}) {
40
85
  if (!p.success) return { success: false, step: extra.step, error: p.error }
@@ -50,6 +95,64 @@ function summarizePreview(p, extra = {}) {
50
95
  }
51
96
  }
52
97
 
98
+ /** Preview with the note's commitment already in `data`; queue the reveal under the preview's requestId. */
99
+ function summarizeWithNote(p, extra, note, kind) {
100
+ const s = summarizePreview(p, extra)
101
+ if (note && s.success) {
102
+ queueNote(note, { kind, token: extra.token ?? null, requestId: s.requestId })
103
+ s.agentNote = { attached: true, hash: note.hash, text: note.text, conf: note.conf, visibility: 'public after execute' }
104
+ }
105
+ return s
106
+ }
107
+ const noteSkipped = (note) =>
108
+ note ? { agentNote: { attached: false, reason: 'approve step carries no note; pass --note again on the re-run' } } : {}
109
+
110
+ /** Reveal after the tx is mined (the server checks receipt status). Failures don't affect the trade. */
111
+ async function revealNote(entry, hash) {
112
+ let receipt
113
+ try {
114
+ receipt = await client.waitForTransactionReceipt({ hash, timeout: 60_000 })
115
+ } catch (e) {
116
+ // viem keeps polling through RPC errors, so an unreachable RPC also ends up as the 60s timeout
117
+ const timedOut = e.name === 'WaitForTransactionReceiptTimeoutError'
118
+ return {
119
+ published: false, error: timedOut ? 'receipt_timeout' : 'rpc_error',
120
+ detail: timedOut ? 'no receipt within 60s (not mined yet, or RPC unreachable)' : e.shortMessage || e.message.split('\n')[0],
121
+ retry: `gang tx --hash ${hash}`,
122
+ }
123
+ }
124
+ if (receipt.status !== 'success') return { published: false, error: 'tx_failed' }
125
+ return withRetry(await publishNote(entry, hash), hash)
126
+ }
127
+ // only transient failures are worth a retry (network, 5xx incl. rpc_unavailable, rate_limited, tx_not_found);
128
+ // conflict / hash_mismatch / reveal_expired / bad_* are final
129
+ const withRetry = (r, hash) =>
130
+ !r.published && (!r.httpStatus || r.httpStatus >= 500 || r.httpStatus === 429 || r.error === 'tx_not_found')
131
+ ? { ...r, retry: `gang tx --hash ${hash}` } : r
132
+
133
+ /** --quote / --ref-pool: optional addresses, 0x0 when omitted. Mixed-case input must carry a valid checksum. */
134
+ function createAddr(name) {
135
+ if (!flag(name)) return zeroAddress
136
+ const v = opt(name)
137
+ if (!isAddress(v ?? '')) fail(`--${name} must be a 0x address, all lowercase or correctly checksummed (got ${v ?? 'nothing'})`)
138
+ return v
139
+ }
140
+
141
+ const CREATE_HINTS = {
142
+ NotAgent: 'this wallet holds no Agent ID (.ai)',
143
+ RefPoolRequired: 'a non-WBNB --quote needs --ref-pool: a Pancake V2 pair or V3 pool that holds the quote token',
144
+ QuoteNotInPool: '--ref-pool does not contain the --quote token',
145
+ PoolNotFactory: '--ref-pool is not a PancakeSwap V2 pair / V3 pool',
146
+ NotContract: '--ref-pool is not a contract',
147
+ UnknownPoolType: '--ref-pool is not a PancakeSwap V2 pair / V3 pool',
148
+ ThinReferencePool: '--ref-pool is too shallow; pick the deepest pool for this quote',
149
+ PoolNoLiquidity: '--ref-pool has no liquidity',
150
+ NoBridgePool: "the pool's other side has no BNB bridge configured; use a pool paired with WBNB",
151
+ BridgeNotWbnb: "the pool's other side has no BNB bridge configured; use a pool paired with WBNB",
152
+ GradAmtTooSmall: 'the quote token is priced so high that the graduation amount rounds below the minimum',
153
+ GradAmtZero: 'the quote token is priced so high that the graduation amount rounds below the minimum',
154
+ }
155
+
53
156
  async function resolveLogoUri() {
54
157
  const uri = opt('logo-uri')
55
158
  const file = opt('logo') || opt('logo-file') || opt('file-logo')
@@ -87,6 +190,85 @@ async function main() {
87
190
  const me = bawStatus() === 'CONNECTED' ? bawAddress() : undefined
88
191
  return out({ success: true, createPad: ADDR.v2, tokens: await listTokens(me) })
89
192
  }
193
+ case 'hot':
194
+ case 'new':
195
+ case 'near-grad': {
196
+ const limit = parseCount('limit', opt('limit', '10'), 100)
197
+ const minPct = parsePct(opt('min-pct', '50'))
198
+ const maxAgeSec = opt('max-age') ? parseDuration(opt('max-age'), 'max-age') : null
199
+ const track = opt('track', 'all')
200
+ const includeGraduated = flag('include-graduated')
201
+ const market = await fetchMarket(track)
202
+ const tokens = rank(market.tokens, cmd, { limit, minPct, maxAgeSec, includeGraduated })
203
+ const empty = {
204
+ hot: includeGraduated ? 'No token traded in the last 24h.' : 'No curve token traded in the last 24h.',
205
+ new: maxAgeSec != null ? `No curve token created in the last ${opt('max-age')}.` : 'No curve tokens.',
206
+ 'near-grad': `No curve token at ≥${minPct}% of graduation; lower --min-pct to see the closest ones.`,
207
+ }[cmd]
208
+ return out({
209
+ success: true, source: API, sort: cmd, track, count: tokens.length,
210
+ ...(cmd === 'near-grad' ? { minPct } : {}), ...(maxAgeSec != null ? { maxAgeSec } : {}),
211
+ ...(market.truncated ? { truncated: true } : {}),
212
+ tokens,
213
+ note: tokens.length ? 'Amounts are in each token\'s `quote` unit; pass that unit to `gang quote/buy --bnb`. Data, not advice.' : empty,
214
+ })
215
+ }
216
+ case 'watch': {
217
+ const positional = args[1] && !args[1].startsWith('--') ? args[1] : null
218
+ if (positional && flag('token')) fail('give the token either as <addr> or via --token, not both')
219
+ if (!positional && flag('token') && !opt('token')) fail('--token needs an address (or omit it to watch the whole market)')
220
+ const token = positional || opt('token')
221
+ if (token && !/^0x[0-9a-fA-F]{40}$/.test(token)) fail('watch: token must be a 0x… address', { got: token })
222
+ // market mode pulls the full token list every tick, so it gets a higher floor than a single token
223
+ const intervalSec = Math.max(token ? 3 : 10, parseDuration(opt('interval', '10'), 'interval'))
224
+ const maxEvents = opt('max-events') ? parseCount('max-events', opt('max-events'), 1e6) : Infinity
225
+ const deadline = opt('timeout') ? Date.now() + parseDuration(opt('timeout'), 'timeout') * 1000 : Infinity
226
+ const minPct = parsePct(opt('min-pct', '50'))
227
+ const webhook = opt('webhook')
228
+ if (webhook && !/^https?:\/\//.test(webhook)) fail('--webhook must be an http(s) URL', { got: webhook })
229
+ let events = null
230
+ if (opt('events')) {
231
+ events = new Set(opt('events').split(',').map((s) => s.trim().replace(/-/g, '_')).filter(Boolean))
232
+ const bad = [...events].filter((e) => !WATCH_EVENTS.includes(e))
233
+ if (bad.length) fail(`unknown --events: ${bad.join(', ')}`, { allowed: WATCH_EVENTS })
234
+ }
235
+
236
+ let count = 0
237
+ const hooks = new Set()
238
+ const line = (o) => console.log(JSON.stringify({ ...o, emittedAt: Math.floor(Date.now() / 1000) }))
239
+ // meta events (watching / error / ended / timeout) go to stdout only and do not count toward --max-events
240
+ const emit = (ev, { meta = false } = {}) => {
241
+ line(ev)
242
+ if (meta) return
243
+ count++
244
+ if (webhook) {
245
+ const p = fetch(webhook, {
246
+ method: 'POST', headers: { 'content-type': 'application/json' },
247
+ body: JSON.stringify(ev), signal: AbortSignal.timeout(10_000),
248
+ }).then((r) => { if (!r.ok) line({ event: 'error', error: `webhook ${r.status}` }) })
249
+ .catch((e) => line({ event: 'error', error: `webhook: ${e.message}` }))
250
+ .finally(() => hooks.delete(p))
251
+ hooks.add(p)
252
+ }
253
+ }
254
+ // aborts in-flight API requests at the deadline so --timeout isn't stretched by a slow API.
255
+ // setTimeout caps at 2^31-1 ms (~24.8d) and fires at once beyond it; longer timeouts rely on sleep's check.
256
+ const stopper = new AbortController()
257
+ const delay = deadline - Date.now()
258
+ const deadlineTimer = delay <= 2 ** 31 - 1 ? setTimeout(() => stopper.abort(), delay) : null
259
+ // the timer runs on the monotonic clock and may beat Date.now() by a hair; treat its firing as the deadline
260
+ const shouldStop = () => count >= maxEvents || stopper.signal.aborted || Date.now() >= deadline
261
+ try {
262
+ await watch({ token, intervalSec, minPct, track: opt('track', 'all'), events, emit, shouldStop, stop: stopper.signal })
263
+ } catch (e) {
264
+ if (!shouldStop()) throw e // startup failure → fail() prints it as one NDJSON line
265
+ } finally {
266
+ clearTimeout(deadlineTimer)
267
+ }
268
+ if (count < maxEvents && (stopper.signal.aborted || Date.now() >= deadline)) line({ event: 'timeout', events: count })
269
+ await Promise.allSettled([...hooks])
270
+ return
271
+ }
90
272
  case 'token': {
91
273
  const token = opt('token'); if (!token) fail('--token required')
92
274
  const me = bawStatus() === 'CONNECTED' ? bawAddress() : undefined
@@ -108,6 +290,7 @@ async function main() {
108
290
  case 'buy': {
109
291
  const token = opt('token'), bnb = opt('bnb'); if (!token || !bnb) fail('--token and --bnb required')
110
292
  const slippage = parseSlippage(opt('slippage', '1'))
293
+ const note = parseNote()
111
294
  const me = bawAddress()
112
295
  const info = await tokenInfo(token, me)
113
296
  if (info.graduated) fail('token already graduated; trade it on PancakeSwap', { pair: info.pair })
@@ -118,13 +301,13 @@ async function main() {
118
301
  const minOut = (q.tokensOut * BigInt(Math.round((100 - slippage) * 100))) / 10000n
119
302
 
120
303
  if (pad.kind === 'soft') {
121
- const data = calldata.softBuy(token, minOut)
304
+ const data = withNote(calldata.softBuy(token, minOut), note)
122
305
  const p = bawPreview({ from: me, to: pad.address, value: parseEther(bnb), data })
123
- return out(summarizePreview(p, {
306
+ return out(summarizeWithNote(p, {
124
307
  step: 'buy', kind: 'soft', token, symbol: info.symbol, pad: pad.id,
125
308
  bnbSent: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt,
126
309
  expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut),
127
- }))
310
+ }, note, 'buy'))
128
311
  }
129
312
 
130
313
  // V2 / U / S: quote ERC-20 approve then buy(token, quoteIn, minTokensOut)
@@ -135,18 +318,20 @@ async function main() {
135
318
  return out(summarizePreview(p, {
136
319
  step: 'approve-quote', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
137
320
  amount: bnb, note: 'Approve quote ERC-20 first. After it confirms, re-run the same `gang buy` to preview the buy.',
321
+ ...noteSkipped(note),
138
322
  }))
139
323
  }
140
- const data = calldata.v2Buy(token, quoteIn, minOut)
324
+ const data = withNote(calldata.v2Buy(token, quoteIn, minOut), note)
141
325
  const p = bawPreview({ from: me, to: pad.address, data })
142
- return out(summarizePreview(p, {
326
+ return out(summarizeWithNote(p, {
143
327
  step: 'buy', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
144
328
  quoteIn: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt,
145
329
  expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut), quotePath: q.quotePath, deterministic: q.deterministic || false,
146
- }))
330
+ }, note, 'buy'))
147
331
  }
148
332
  case 'sell': {
149
333
  const token = opt('token'); if (!token) fail('--token required')
334
+ const note = parseNote()
150
335
  const me = bawAddress()
151
336
  const info = await tokenInfo(token, me)
152
337
  if (info.graduated) fail('token already graduated; sell on PancakeSwap', { pair: info.pair })
@@ -163,23 +348,24 @@ async function main() {
163
348
  return out(summarizePreview(p, {
164
349
  step: 'approve', token, symbol: info.symbol, pad: pad.id, amount: fmtTok(amount),
165
350
  note: 'Approve first. After it confirms on-chain, run the same `gang sell` again to preview the sell.',
351
+ ...noteSkipped(note),
166
352
  }))
167
353
  }
168
354
  const slippage = parseSlippage(opt('slippage', '1'))
169
355
  const q = await quoteSell(token, amount)
170
356
  const minOut = (q.out * BigInt(Math.round((100 - slippage) * 100))) / 10000n
171
357
  if (pad.kind === 'soft') {
172
- const p = bawPreview({ from: me, to: pad.address, data: calldata.softSell(token, amount, minOut) })
173
- return out(summarizePreview(p, {
358
+ const p = bawPreview({ from: me, to: pad.address, data: withNote(calldata.softSell(token, amount, minOut), note) })
359
+ return out(summarizeWithNote(p, {
174
360
  step: 'sell', kind: 'soft', token, symbol: info.symbol, pad: pad.id,
175
361
  amount: fmtTok(amount), expectOut: q.outFmt, fee: q.feeFmt, minOut: fmt(minOut), unit: 'BNB',
176
- }))
362
+ }, note, 'sell'))
177
363
  }
178
- const p = bawPreview({ from: me, to: pad.address, data: calldata.v2Sell(token, amount, minOut) })
179
- return out(summarizePreview(p, {
364
+ const p = bawPreview({ from: me, to: pad.address, data: withNote(calldata.v2Sell(token, amount, minOut), note) })
365
+ return out(summarizeWithNote(p, {
180
366
  step: 'sell', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
181
367
  amount: fmtTok(amount), expectOut: q.outFmt, fee: q.feeFmt, minOut: fmt(minOut), unit: 'quote', quotePath: q.quotePath, deterministic: q.deterministic || false,
182
- }))
368
+ }, note, 'sell'))
183
369
  }
184
370
  case 'upload-logo': {
185
371
  const file = opt('file') || opt('path') || opt('logo')
@@ -190,22 +376,32 @@ async function main() {
190
376
  case 'create': {
191
377
  const name = opt('name'), symbol = opt('symbol'); if (!name || !symbol) fail('--name and --symbol required')
192
378
  try { assertCreateTarget(CONFIG.launchpad) } catch (e) { fail(e.message) }
379
+ const note = parseNote()
380
+ // the old 5-arg --pair-or-quote never matched the contract
381
+ if (flag('pair-or-quote') || flag('pair')) {
382
+ fail('--pair-or-quote was removed (CLI ≤0.3.0 encoded a create the contract does not have). Use --quote <ERC-20> --ref-pool <Pancake pair/pool holding it>; omit both for WBNB.')
383
+ }
384
+ const taxed = flag('taxed')
385
+ const quote = createAddr('quote'), refPool = createAddr('ref-pool')
386
+ const wbnbQuote = [zeroAddress, ADDR.wbnb.toLowerCase()].includes(quote.toLowerCase())
193
387
  const me = bawAddress()
194
388
  if (!(await isAgent(me))) fail('this wallet holds no Agent ID (.ai); create is gated', { address: me })
389
+ // dry run before any logo upload; logoURI and the note suffix can't change whether create reverts
390
+ const dry = await preflightCreate(me, calldata.createV2(name, symbol, '', taxed, quote, refPool), quote, refPool)
391
+ if (!dry.ok && dry.reverted) fail(`create would revert: ${dry.error}`, { quote, refPool, hint: CREATE_HINTS[dry.error] })
392
+ if (!dry.ok) fail(`could not dry-run create (RPC): ${dry.error}`, { retry: 'same command again; set GANG_RPC_URL if this RPC keeps failing' })
195
393
  const logo = await resolveLogoUri()
196
- const taxed = flag('taxed')
197
- const pairOrQuote = opt('pair-or-quote') || opt('pair') || zeroAddress
198
- if (taxed && (!pairOrQuote || pairOrQuote === zeroAddress)) {
199
- fail('--taxed requires --pair-or-quote <pair or quote CA>')
200
- }
201
- const data = calldata.createV2(name, symbol, logo.logoURI, taxed, pairOrQuote)
394
+ const data = withNote(calldata.createV2(name, symbol, logo.logoURI, taxed, quote, refPool), note)
202
395
  const p = bawPreview({ from: me, to: ADDR.v2, data })
203
- return out(summarizePreview(p, {
396
+ return out(summarizeWithNote(p, {
204
397
  step: 'create', pad: 'v2', launchpad: ADDR.v2, name, symbol,
205
398
  logoURI: logo.logoURI || '(empty)', uploaded: logo.uploaded, upload: logo.upload,
206
- taxed, pairOrQuote,
207
- note: 'LaunchpadV2 only. Graduation ≈15 BNB (PoolCheck). No creation fee. Soft/U/S create archived. After execute: `gang tx --hash …` for Created.',
208
- }))
399
+ taxed, quote: wbnbQuote ? 'WBNB' : quote,
400
+ // the contract ignores refPool for WBNB (Created.refPair = 0x0)
401
+ refPool: wbnbQuote && refPool !== zeroAddress ? `${refPool} (ignored for WBNB)` : refPool,
402
+ graduationAmount: dry.gradAmt, tokenIfNext: dry.tokenIfNext,
403
+ note: 'LaunchpadV2 only. Graduation ≈15 BNB worth of the quote. No creation fee. Soft/U/S create archived. tokenIfNext changes if another create lands first: the real address is the Created event from `gang tx --hash …` after execute.',
404
+ }, note, 'launch'))
209
405
  }
210
406
  case 'execute': {
211
407
  const id = opt('request-id'); if (!id) fail('--request-id required')
@@ -213,8 +409,18 @@ async function main() {
213
409
  if (!r.success) return out({ success: false, error: r.error })
214
410
  const d = r.data
215
411
  const pending = d.status === 'PENDING_CONFIRMATION'
412
+ const entry = findByRequestId(id)
413
+ let note
414
+ if (entry && d.txHash && !pending) note = await revealNote(entry, d.txHash)
415
+ else if (entry && pending) {
416
+ note = { published: false, pending: true, retry: 'after the user approves in the Binance App: `gang tx --hash <hash>` publishes the note' }
417
+ } else if (entry) {
418
+ // any other status without a hash: nothing was broadcast, so there's nothing to reveal (and nothing to wait for)
419
+ note = { published: false, error: 'no_tx_hash', status: d.status }
420
+ }
216
421
  return out({
217
422
  success: true, status: d.status, txHash: d.txHash, orderId: d.orderId, pendingConfirmation: pending,
423
+ ...(note && { note }),
218
424
  next: pending
219
425
  ? '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 <hash>`.'
220
426
  : (d.txHash ? `gang tx --hash ${d.txHash}` : undefined),
@@ -222,7 +428,26 @@ async function main() {
222
428
  }
223
429
  case 'tx': {
224
430
  const hash = opt('hash'); if (!hash) fail('--hash required')
225
- return out({ success: true, ...(await decodeTx(hash)) })
431
+ const t = await decodeTx(hash)
432
+ // a note committed in this tx (e.g. after PENDING_CONFIRMATION) is revealed here if it's still in the local queue;
433
+ // with nothing queued there's nothing to reveal, so skip the extra RPC read
434
+ let note
435
+ if (hasUnpublished()) {
436
+ try {
437
+ const committed = hashesIn((await client.getTransaction({ hash })).input)
438
+ if (committed.length) {
439
+ const entry = findByHashes(committed)
440
+ if (!entry) {
441
+ 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' }
442
+ }
443
+ else if (t.status !== 'success') note = { published: false, error: 'tx_failed' }
444
+ else note = withRetry(await publishNote(entry, hash), hash)
445
+ }
446
+ } catch (e) {
447
+ note = { published: false, error: `could not read tx input: ${e.message.split('\n')[0]}` }
448
+ }
449
+ }
450
+ return out({ success: true, ...t, ...(note && { note }) })
226
451
  }
227
452
  default:
228
453
  console.log(HELP); process.exit(cmd ? 1 : 0)
package/src/notes.js ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Agent notes ("thoughts"): a short public reason attached to a buy / sell / create.
3
+ * baw can't sign messages, so the commitment rides at the end of the calldata (Solidity ignores trailing bytes):
4
+ * suffix = "gang" 0x01 keccak256(UTF-8("gang-note:v1\n" salt "\n" conf "\n" text)) (37 bytes)
5
+ * The text is revealed after the tx lands (POST /api/notes), so anyone can check it was written before the trade.
6
+ * Normalization and the commitment format must match the server byte for byte.
7
+ */
8
+ import { randomBytes } from 'node:crypto'
9
+ import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
10
+ import { homedir } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import { keccak256, toHex } from 'viem'
13
+ import { API } from './discover.js'
14
+
15
+ export const MAGIC = '67616e6701' // "gang" + version 1
16
+ const MAX_CP = 280
17
+ const LINK = /https?:\/\/|www\.|t\.me\//i
18
+ // zero-width + bidi controls, and every C0/C1 control except the whitespace ones (\t \n \v \f \r become a space below)
19
+ const STRIP = /[\u200B-\u200D\uFEFF\u202A-\u202E\u2066-\u2069]|(?![\t\n\v\f\r])\p{Cc}/gu
20
+ const TTL_MS = 24 * 3600_000
21
+ const DIR = process.env.GANG_NOTES_DIR || join(homedir(), '.gang', 'notes')
22
+ const ENTRY = /^0x[0-9a-f]{64}\.json$/
23
+ const TMP = /^0x[0-9a-f]{64}\.json\.\d+\.tmp$/ // left behind only if a process died between write and rename
24
+
25
+ /** NFC → strip invisibles/controls → collapse whitespace → trim. Throws with the server's error code in the message. */
26
+ export function normalizeNote(raw) {
27
+ const text = String(raw ?? '').normalize('NFC').replace(STRIP, '').replace(/\s+/gu, ' ').trim()
28
+ const n = [...text].length
29
+ if (n < 1 || n > MAX_CP) throw new Error(`bad_text: note must be 1–${MAX_CP} characters after normalization (got ${n})`)
30
+ if (LINK.test(text)) throw new Error('link_not_allowed: note must not contain links (http://, www., t.me/)')
31
+ return text
32
+ }
33
+
34
+ /** conf is optional; null serializes as '' in the preimage. */
35
+ export function parseConf(raw) {
36
+ if (raw == null) return null
37
+ if (!/^\d{1,3}$/.test(String(raw)) || Number(raw) > 100) throw new Error(`bad_conf: --conf must be an integer 0–100 (got ${raw})`)
38
+ return Number(raw)
39
+ }
40
+
41
+ export const noteHash = ({ salt, conf, text }) =>
42
+ keccak256(toHex(`gang-note:v1\n${salt}\n${conf ?? ''}\n${text}`))
43
+
44
+ export function makeNote(rawText, rawConf) {
45
+ const text = normalizeNote(rawText)
46
+ const conf = parseConf(rawConf)
47
+ const salt = randomBytes(16).toString('hex')
48
+ const hash = noteHash({ salt, conf, text })
49
+ return { text, conf, salt, hash, suffix: MAGIC + hash.slice(2) }
50
+ }
51
+
52
+ export const withNote = (data, note) => (note ? data + note.suffix : data)
53
+
54
+ /** Commitment hashes found in a tx input (contains-match, same as the server: the call may be wrapped). */
55
+ export function hashesIn(input) {
56
+ const s = String(input || '').toLowerCase()
57
+ return [...s.matchAll(/67616e6701([0-9a-f]{64})/g)].map((m) => `0x${m[1]}`)
58
+ }
59
+
60
+ // ---- local queue: one file per note, <dir>/<hash>.json → { salt, conf, text, kind, token, requestId, createdAt,
61
+ // publishedTx, publishedStatus }. Each process only ever writes its own note's file (atomic rename), so concurrent
62
+ // `gang` commands need no lock and a damaged file can't take the other notes' salts down with it.
63
+
64
+ const pathOf = (hash) => join(DIR, `${hash}.json`)
65
+
66
+ function read(hash) {
67
+ try { return JSON.parse(readFileSync(pathOf(hash), 'utf8')) } catch { return null }
68
+ }
69
+
70
+ function write(hash, entry) {
71
+ mkdirSync(DIR, { recursive: true, mode: 0o700 })
72
+ const tmp = `${pathOf(hash)}.${process.pid}.tmp`
73
+ writeFileSync(tmp, JSON.stringify(entry, null, 2), { mode: 0o600 })
74
+ renameSync(tmp, pathOf(hash))
75
+ }
76
+
77
+ const fresh = (e) => e?.createdAt > Date.now() - TTL_MS
78
+
79
+ const old = (path) => Date.now() - statSync(path).mtimeMs > TTL_MS
80
+
81
+ /** Live entries; entries, unreadable files and stray temp files past the 24h reveal window are deleted. */
82
+ function entries() {
83
+ let names
84
+ try { names = readdirSync(DIR) } catch { return [] }
85
+ const live = []
86
+ for (const name of names) {
87
+ const path = join(DIR, name)
88
+ if (TMP.test(name)) {
89
+ try { if (old(path)) unlinkSync(path) } catch { /* already gone */ }
90
+ continue
91
+ }
92
+ if (!ENTRY.test(name)) continue
93
+ const hash = name.slice(0, -5)
94
+ const e = read(hash)
95
+ if (fresh(e)) { live.push({ hash, ...e }); continue }
96
+ try {
97
+ // an unreadable file (e.g. damaged by hand) is kept for 24h so its salt can still be recovered manually
98
+ if (e || old(path)) unlinkSync(path)
99
+ } catch { /* already gone */ }
100
+ }
101
+ return live
102
+ }
103
+
104
+ export function queueNote(note, meta) {
105
+ write(note.hash, { salt: note.salt, conf: note.conf, text: note.text, ...meta, createdAt: Date.now(), publishedTx: null })
106
+ }
107
+
108
+ export const hasUnpublished = () => entries().some((e) => !e.publishedTx)
109
+
110
+ export const findByRequestId = (requestId) => entries().find((e) => e.requestId === requestId) ?? null
111
+
112
+ export function findByHashes(hashes) {
113
+ for (const hash of hashes) {
114
+ const e = read(hash)
115
+ if (fresh(e)) return { hash, ...e }
116
+ }
117
+ return null
118
+ }
119
+
120
+ function markPublished(hash, tx, status) {
121
+ const e = read(hash)
122
+ if (e) write(hash, { ...e, publishedTx: tx, publishedStatus: status })
123
+ }
124
+
125
+ /**
126
+ * Reveal a queued note for a landed tx. Never throws: the trade already happened, so a failed reveal is
127
+ * reported (and stays queued for `gang tx --hash` to retry within 24h) instead of failing the command.
128
+ */
129
+ export async function publishNote(entry, tx) {
130
+ if (entry.publishedTx === tx) return { published: true, status: entry.publishedStatus || 'ok', already: true }
131
+ const ctl = new AbortController()
132
+ const timer = setTimeout(() => ctl.abort(), 10_000)
133
+ try {
134
+ const r = await fetch(`${API}/notes`, {
135
+ method: 'POST', signal: ctl.signal,
136
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
137
+ body: JSON.stringify({ tx, salt: entry.salt, conf: entry.conf, text: entry.text }),
138
+ })
139
+ if (r.status === 200 || r.status === 202) {
140
+ let body
141
+ try { body = await r.json() } catch { /* status code is enough */ }
142
+ // ok | hidden (matched the blocklist, not shown) | pending (server waits for the indexer)
143
+ const status = body?.status || (r.status === 202 ? 'pending' : 'ok')
144
+ try { markPublished(entry.hash, tx, status) } catch { /* published anyway; a later `gang tx` just gets 200 again */ }
145
+ return { published: true, status }
146
+ }
147
+ let code
148
+ try { code = (await r.json())?.error } catch { /* non-JSON error body */ }
149
+ return { published: false, error: code || `http_${r.status}`, httpStatus: r.status }
150
+ } catch (e) {
151
+ return { published: false, error: `unreachable: ${ctl.signal.aborted ? 'timed out after 10s' : e.message}` }
152
+ } finally {
153
+ clearTimeout(timer)
154
+ }
155
+ }