@gangfun/cli 0.3.0 → 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
@@ -45,10 +45,10 @@ gang near-grad [--min-pct 50] [--limit 10]
45
45
  gang watch [<addr>] [--interval 10] [--events a,b] [--max-events N] [--timeout 30m] [--webhook URL]
46
46
  gang token --token <addr>
47
47
  gang quote --token <addr> --bnb <n> # Soft archive: BNB; V2: quote ERC-20 (LaunchpadV2.sol math)
48
- gang buy --token <addr> --bnb <n> [--slippage 1]
49
- 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 …]]
50
50
  gang upload-logo --file ./avatar.png # → https://gang.fun/api/logos/<id>.png
51
- 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 …]
52
52
  gang execute --request-id <id>
53
53
  gang tx --hash <0x..>
54
54
  ```
@@ -69,7 +69,9 @@ gang create --name "My Agent Coin" --symbol MAC --logo ./avatar.png
69
69
  gang execute --request-id …
70
70
  ```
71
71
 
72
- `--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.
73
75
 
74
76
  Buy / sell / create: **preview → you confirm → execute**. `requestId` expires in ~5 minutes.
75
77
 
@@ -87,6 +89,17 @@ Without it, it emits `new_token` / `near_grad` (crosses `--min-pct`) / `graduate
87
89
  gang watch --events new_token,near_grad --max-events 1 --timeout 30m
88
90
  ```
89
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
+
90
103
  ## Quote path
91
104
 
92
105
  - **Soft archive tokens:** on-chain `quoteBuy` / `quoteSell`.
@@ -107,8 +120,9 @@ gang watch --events new_token,near_grad --max-events 1 --timeout 30m
107
120
  | `GANG_LAUNCHPAD` | Create target (default LaunchpadV2 `0x1B8F3f69…966E`) |
108
121
  | `GANG_RPC_URL` | BSC HTTP RPC |
109
122
  | `GANG_LOGO_API` | Logo upload endpoint (default `https://gang.fun/api/logos`) |
110
- | `GANG_API` | Indexer API for discovery (default `https://gang.fun/api`) |
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`) |
111
125
 
112
126
  ## Version
113
127
 
114
- `0.3.0` · repo [gangfun/cli](https://github.com/gangfun/cli) · published to [npm](https://www.npmjs.com/package/@gangfun/cli) on push to `main`
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
@@ -35,10 +35,10 @@ gang near-grad [--min-pct 50] [--limit 10]
35
35
  gang watch [<addr>] [--interval 10] [--events a,b] [--max-events N] [--timeout 30m] [--webhook URL]
36
36
  gang token --token <addr>
37
37
  gang quote --token <addr> --bnb <n>
38
- gang buy --token <addr> --bnb <n> [--slippage 1]
39
- 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 …]]
40
40
  gang upload-logo --file ./avatar.png
41
- 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 …]
42
42
  gang execute --request-id <id>
43
43
  gang tx --hash <0x..>
44
44
  ```
@@ -57,6 +57,10 @@ gang create --name "My Agent Coin" --symbol MAC --logo ./avatar.png
57
57
  gang execute --request-id …
58
58
  ```
59
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
+
60
64
  ### 发现(0.3.0+)
61
65
 
62
66
  `hot` / `new` / `near-grad` / `watch` 读 gang.fun indexer(`GANG_API`),同时覆盖 V2 与 Soft。每行带 `quote`(`--bnb` 的单位)、
@@ -65,6 +69,17 @@ gang execute --request-id …
65
69
  `gang watch` 轮询并输出 NDJSON(最短间隔:单币 3s,全市场 10s):带 `<addr>` 推 `trade` / `progress`(25/50/75/90%)/ `graduated`;不带则推 `new_token` / `near_grad`(越过 `--min-pct`)/ `graduated`。
66
70
  `--max-events 1` = 阻塞到下一个事件;`--webhook` 把每个事件 POST 出去。详见 [skill/references/discover.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/discover.md)。
67
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
+
68
83
  ## Quote 路径
69
84
 
70
85
  - Soft 存量:链上 `quoteBuy` / `quoteSell`。
@@ -83,8 +98,9 @@ gang execute --request-id …
83
98
  | `GANG_LAUNCHPAD` | 发币目标(默认 V2) |
84
99
  | `GANG_RPC_URL` | BSC RPC |
85
100
  | `GANG_LOGO_API` | Logo 上传 API(默认 `https://gang.fun/api/logos`) |
86
- | `GANG_API` | 发现命令用的 indexer API(默认 `https://gang.fun/api`) |
101
+ | `GANG_API` | indexer API,发现命令和公开附言都用它(默认 `https://gang.fun/api`) |
102
+ | `GANG_NOTES_DIR` | 未公开附言的本地队列,每条一个文件(默认 `~/.gang/notes`) |
87
103
 
88
104
  ## 版本
89
105
 
90
- `0.3.0` · [gangfun/cli](https://github.com/gangfun/cli) · push 到 `main` 自动发布到 [npm](https://www.npmjs.com/package/@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.3.0",
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 })
@@ -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) {
package/src/index.js CHANGED
@@ -1,11 +1,12 @@
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
8
  import { API, fetchMarket, rank, watch, parseDuration } from './discover.js'
9
+ import { makeNote, withNote, queueNote, findByRequestId, findByHashes, hashesIn, publishNote, hasUnpublished } from './notes.js'
9
10
 
10
11
  const args = process.argv.slice(2)
11
12
  const cmd = args[0]
@@ -37,6 +38,21 @@ const parsePct = (raw) => {
37
38
  }
38
39
  const WATCH_EVENTS = ['trade', 'progress', 'graduated', 'new_token', 'near_grad']
39
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
+
40
56
  const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet) · LaunchpadV2 create
41
57
 
42
58
  gang preflight check sign-in, Developer Mode, tradeAllTokens, Agent ID
@@ -50,16 +66,20 @@ const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet
50
66
  no addr: new_token / near_grad / graduated across the market
51
67
  gang token --token <addr> one token (auto-resolves pad)
52
68
  gang quote --token <addr> --bnb <n> Soft: BNB quote; V2/U/S: quote-token amount (same flag)
53
- gang buy --token <addr> --bnb <n> [--slippage 1] Soft: payable BNB; V2: approve quote then buy
54
- 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 …]]
55
72
  gang upload-logo --file <path> POST image → https://gang.fun/api/logos → logoUrl
56
- 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 …]]
57
74
  LaunchpadV2 ONLY (${ADDR.v2}); Soft/U/S create refused
58
75
  gang execute --request-id <id> broadcast a previewed call
59
- 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.
60
80
 
61
81
  Logo UI (browser): ${LOGO_UI}
62
- env: GANG_LAUNCHPAD (default V2 ${ADDR.v2}), GANG_RPC_URL, GANG_LOGO_API, GANG_API (default ${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)`
63
83
 
64
84
  function summarizePreview(p, extra = {}) {
65
85
  if (!p.success) return { success: false, step: extra.step, error: p.error }
@@ -75,6 +95,64 @@ function summarizePreview(p, extra = {}) {
75
95
  }
76
96
  }
77
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
+
78
156
  async function resolveLogoUri() {
79
157
  const uri = opt('logo-uri')
80
158
  const file = opt('logo') || opt('logo-file') || opt('file-logo')
@@ -212,6 +290,7 @@ async function main() {
212
290
  case 'buy': {
213
291
  const token = opt('token'), bnb = opt('bnb'); if (!token || !bnb) fail('--token and --bnb required')
214
292
  const slippage = parseSlippage(opt('slippage', '1'))
293
+ const note = parseNote()
215
294
  const me = bawAddress()
216
295
  const info = await tokenInfo(token, me)
217
296
  if (info.graduated) fail('token already graduated; trade it on PancakeSwap', { pair: info.pair })
@@ -222,13 +301,13 @@ async function main() {
222
301
  const minOut = (q.tokensOut * BigInt(Math.round((100 - slippage) * 100))) / 10000n
223
302
 
224
303
  if (pad.kind === 'soft') {
225
- const data = calldata.softBuy(token, minOut)
304
+ const data = withNote(calldata.softBuy(token, minOut), note)
226
305
  const p = bawPreview({ from: me, to: pad.address, value: parseEther(bnb), data })
227
- return out(summarizePreview(p, {
306
+ return out(summarizeWithNote(p, {
228
307
  step: 'buy', kind: 'soft', token, symbol: info.symbol, pad: pad.id,
229
308
  bnbSent: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt,
230
309
  expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut),
231
- }))
310
+ }, note, 'buy'))
232
311
  }
233
312
 
234
313
  // V2 / U / S: quote ERC-20 approve then buy(token, quoteIn, minTokensOut)
@@ -239,18 +318,20 @@ async function main() {
239
318
  return out(summarizePreview(p, {
240
319
  step: 'approve-quote', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
241
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),
242
322
  }))
243
323
  }
244
- const data = calldata.v2Buy(token, quoteIn, minOut)
324
+ const data = withNote(calldata.v2Buy(token, quoteIn, minOut), note)
245
325
  const p = bawPreview({ from: me, to: pad.address, data })
246
- return out(summarizePreview(p, {
326
+ return out(summarizeWithNote(p, {
247
327
  step: 'buy', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
248
328
  quoteIn: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt,
249
329
  expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut), quotePath: q.quotePath, deterministic: q.deterministic || false,
250
- }))
330
+ }, note, 'buy'))
251
331
  }
252
332
  case 'sell': {
253
333
  const token = opt('token'); if (!token) fail('--token required')
334
+ const note = parseNote()
254
335
  const me = bawAddress()
255
336
  const info = await tokenInfo(token, me)
256
337
  if (info.graduated) fail('token already graduated; sell on PancakeSwap', { pair: info.pair })
@@ -267,23 +348,24 @@ async function main() {
267
348
  return out(summarizePreview(p, {
268
349
  step: 'approve', token, symbol: info.symbol, pad: pad.id, amount: fmtTok(amount),
269
350
  note: 'Approve first. After it confirms on-chain, run the same `gang sell` again to preview the sell.',
351
+ ...noteSkipped(note),
270
352
  }))
271
353
  }
272
354
  const slippage = parseSlippage(opt('slippage', '1'))
273
355
  const q = await quoteSell(token, amount)
274
356
  const minOut = (q.out * BigInt(Math.round((100 - slippage) * 100))) / 10000n
275
357
  if (pad.kind === 'soft') {
276
- const p = bawPreview({ from: me, to: pad.address, data: calldata.softSell(token, amount, minOut) })
277
- 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, {
278
360
  step: 'sell', kind: 'soft', token, symbol: info.symbol, pad: pad.id,
279
361
  amount: fmtTok(amount), expectOut: q.outFmt, fee: q.feeFmt, minOut: fmt(minOut), unit: 'BNB',
280
- }))
362
+ }, note, 'sell'))
281
363
  }
282
- const p = bawPreview({ from: me, to: pad.address, data: calldata.v2Sell(token, amount, minOut) })
283
- 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, {
284
366
  step: 'sell', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
285
367
  amount: fmtTok(amount), expectOut: q.outFmt, fee: q.feeFmt, minOut: fmt(minOut), unit: 'quote', quotePath: q.quotePath, deterministic: q.deterministic || false,
286
- }))
368
+ }, note, 'sell'))
287
369
  }
288
370
  case 'upload-logo': {
289
371
  const file = opt('file') || opt('path') || opt('logo')
@@ -294,22 +376,32 @@ async function main() {
294
376
  case 'create': {
295
377
  const name = opt('name'), symbol = opt('symbol'); if (!name || !symbol) fail('--name and --symbol required')
296
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())
297
387
  const me = bawAddress()
298
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' })
299
393
  const logo = await resolveLogoUri()
300
- const taxed = flag('taxed')
301
- const pairOrQuote = opt('pair-or-quote') || opt('pair') || zeroAddress
302
- if (taxed && (!pairOrQuote || pairOrQuote === zeroAddress)) {
303
- fail('--taxed requires --pair-or-quote <pair or quote CA>')
304
- }
305
- const data = calldata.createV2(name, symbol, logo.logoURI, taxed, pairOrQuote)
394
+ const data = withNote(calldata.createV2(name, symbol, logo.logoURI, taxed, quote, refPool), note)
306
395
  const p = bawPreview({ from: me, to: ADDR.v2, data })
307
- return out(summarizePreview(p, {
396
+ return out(summarizeWithNote(p, {
308
397
  step: 'create', pad: 'v2', launchpad: ADDR.v2, name, symbol,
309
398
  logoURI: logo.logoURI || '(empty)', uploaded: logo.uploaded, upload: logo.upload,
310
- taxed, pairOrQuote,
311
- note: 'LaunchpadV2 only. Graduation ≈15 BNB (PoolCheck). No creation fee. Soft/U/S create archived. After execute: `gang tx --hash …` for Created.',
312
- }))
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'))
313
405
  }
314
406
  case 'execute': {
315
407
  const id = opt('request-id'); if (!id) fail('--request-id required')
@@ -317,8 +409,18 @@ async function main() {
317
409
  if (!r.success) return out({ success: false, error: r.error })
318
410
  const d = r.data
319
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
+ }
320
421
  return out({
321
422
  success: true, status: d.status, txHash: d.txHash, orderId: d.orderId, pendingConfirmation: pending,
423
+ ...(note && { note }),
322
424
  next: pending
323
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>`.'
324
426
  : (d.txHash ? `gang tx --hash ${d.txHash}` : undefined),
@@ -326,7 +428,26 @@ async function main() {
326
428
  }
327
429
  case 'tx': {
328
430
  const hash = opt('hash'); if (!hash) fail('--hash required')
329
- 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 }) })
330
451
  }
331
452
  default:
332
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
+ }