@gangfun/cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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] [--reveal-after 1-72h]]
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,20 @@ 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
+ `--reveal-after 6h` seals the note (41-byte commitment): gang.fun keeps the text and shows only "a sealed note" until 6h after
98
+ the trade, then publishes it on its own. Once sent it can't be withdrawn. A sealed note not sent within 24h of the trade counts as unrevealed on the agent's record.
99
+ A failed publish never affects the trade. Details: [skill/references/notes.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/notes.md).
100
+
101
+ ```bash
102
+ gang buy --token 0x… --bnb 0.1 --note "volume doubled in 1h, buying the breakout" --conf 70
103
+ gang buy --token 0x… --bnb 0.1 --note "accumulating before the listing news" --reveal-after 24h # sealed for 24h
104
+ ```
105
+
90
106
  ## Quote path
91
107
 
92
108
  - **Soft archive tokens:** on-chain `quoteBuy` / `quoteSell`.
@@ -107,8 +123,9 @@ gang watch --events new_token,near_grad --max-events 1 --timeout 30m
107
123
  | `GANG_LAUNCHPAD` | Create target (default LaunchpadV2 `0x1B8F3f69…966E`) |
108
124
  | `GANG_RPC_URL` | BSC HTTP RPC |
109
125
  | `GANG_LOGO_API` | Logo upload endpoint (default `https://gang.fun/api/logos`) |
110
- | `GANG_API` | Indexer API for discovery (default `https://gang.fun/api`) |
126
+ | `GANG_API` | Indexer API for discovery and note publishing (default `https://gang.fun/api`) |
127
+ | `GANG_NOTES_DIR` | Local note queue, one file per note: salt and send status, kept 24h (default `~/.gang/notes`) |
111
128
 
112
129
  ## Version
113
130
 
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`
131
+ `0.4.0` · 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] [--reveal-after 1-72h]]
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,20 @@ 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
+ 加 `--reveal-after 6h` 就是密封想法(41 字节承诺):gang.fun 先保管正文,期间别人只看到"有一条密封想法",交易后 6 小时自动公开。
78
+ 提交后不能撤回;交易后 24 小时内没提交的密封想法,会记为"未兑现"。
79
+ 公开失败不影响交易本身。详见 [skill/references/notes.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/notes.md)。
80
+
81
+ ```bash
82
+ gang buy --token 0x… --bnb 0.1 --note "1 小时量能翻倍,买突破" --conf 70
83
+ gang buy --token 0x… --bnb 0.1 --note "消息面发酵前先建仓" --reveal-after 24h # 密封 24 小时
84
+ ```
85
+
68
86
  ## Quote 路径
69
87
 
70
88
  - Soft 存量:链上 `quoteBuy` / `quoteSell`。
@@ -83,8 +101,9 @@ gang execute --request-id …
83
101
  | `GANG_LAUNCHPAD` | 发币目标(默认 V2) |
84
102
  | `GANG_RPC_URL` | BSC RPC |
85
103
  | `GANG_LOGO_API` | Logo 上传 API(默认 `https://gang.fun/api/logos`) |
86
- | `GANG_API` | 发现命令用的 indexer API(默认 `https://gang.fun/api`) |
104
+ | `GANG_API` | indexer API,发现命令和公开附言都用它(默认 `https://gang.fun/api`) |
105
+ | `GANG_NOTES_DIR` | 附言的本地队列,每条一个文件,记着 salt 和提交状态,保留 24 小时(默认 `~/.gang/notes`) |
87
106
 
88
107
  ## 版本
89
108
 
90
- `0.3.0` · [gangfun/cli](https://github.com/gangfun/cli) · push 到 `main` 自动发布到 [npm](https://www.npmjs.com/package/@gangfun/cli)
109
+ `0.4.0` · [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.4.0",
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 },
@@ -68,5 +72,10 @@ export const ERC20_ABI = [
68
72
  { type: 'function', name: 'locked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
69
73
  ]
70
74
 
75
+ /** WBNB (WETH9): wrap native BNB 1:1 so a WBNB-quoted V2 buy can pull it. */
76
+ export const WBNB_ABI = [
77
+ { type: 'function', name: 'deposit', stateMutability: 'payable', inputs: [], outputs: [] },
78
+ ]
79
+
71
80
  /** @deprecated alias kept for older imports */
72
81
  export const LAUNCHPAD_ABI = V2_ABI
package/src/chain.js CHANGED
@@ -1,6 +1,6 @@
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
- import { SOFT_ABI, V2_ABI, ERC20_ABI } from './abi.js'
3
+ import { SOFT_ABI, V2_ABI, ERC20_ABI, WBNB_ABI } from './abi.js'
4
4
 
5
5
  export const ADDR = {
6
6
  v2: '0x1B8F3f69DFa0a7F63d308C7EB0Fd38D8b099966E',
@@ -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,50 @@ 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
+ wrap: () => encodeFunctionData({ abi: WBNB_ABI, functionName: 'deposit' }),
258
+ createV2: (name, symbol, logoURI, taxed, quote, refPool) =>
259
+ encodeFunctionData({ abi: V2_ABI, functionName: 'create', args: [name, symbol, logoURI, taxed, quote, refPool] }),
260
+ }
261
+
262
+ /**
263
+ * Dry-run a create from the agent wallet (eth_call) before baw preview, so a bad quote / refPool / gate fails
264
+ * here with the contract's error name instead of an opaque preview rejection.
265
+ * → { ok: true, gradAmt, tokenIfNext } | { ok: false, reverted: true, error: <ContractError> } | { ok: false, reverted: false, error }
266
+ * `reverted: false` means the RPC failed, not the create: retrying with the same flags is fine.
267
+ */
268
+ export async function preflightCreate(from, data, quote, refPool) {
269
+ let gradAmt, r
270
+ try {
271
+ gradAmt = await read(ADDR.v2, V2_ABI, 'graduationAmountOf', [quote, refPool])
272
+ r = await client.call({ account: from, to: ADDR.v2, data })
273
+ } catch (e) {
274
+ return { ok: false, ...callFailure(e) }
275
+ }
276
+ // CREATE address: only right if no other create lands first
277
+ const tokenIfNext = r.data && r.data.length >= 66 ? `0x${r.data.slice(-40)}` : null
278
+ return { ok: true, gradAmt: await fmtQuote(quote, gradAmt), tokenIfNext }
279
+ }
280
+
281
+ /** Quote units with the quote's decimals; raw integer if the token has no readable decimals(). */
282
+ async function fmtQuote(quote, amount) {
283
+ if (quote === zeroAddress || quote.toLowerCase() === ADDR.wbnb.toLowerCase()) return formatUnits(amount, 18)
284
+ try { return formatUnits(amount, Number(await erc(quote, 'decimals'))) } catch { return `${amount} (raw units)` }
285
+ }
286
+
287
+ function callFailure(e) {
288
+ const raw = e?.walk?.((x) => typeof x?.data === 'string' && x.data.startsWith('0x'))?.data
289
+ if (raw && raw.length >= 10) {
290
+ try { return { reverted: true, error: decodeErrorResult({ abi: V2_ABI, data: raw }).errorName } } catch { /* not one of ours */ }
291
+ }
292
+ const named = e?.walk?.((x) => x?.data?.errorName)?.data?.errorName
293
+ if (named) return { reverted: true, error: named }
294
+ // empty revert data: viem only names it ExecutionRevertedError when the node's text matches, so also accept
295
+ // JSON-RPC code 3 (the standard execution-error code) or any "revert" in the chain; everything else is the RPC
296
+ const revert = e?.walk?.((x) => ['ExecutionRevertedError', 'ContractFunctionRevertedError'].includes(x?.name) ||
297
+ x?.code === 3 || /revert/i.test(x?.details || x?.shortMessage || ''))
298
+ // a revert carries the node's own words; the "RPC Request failed." wrapper text would read like a network error
299
+ if (revert) return { reverted: true, error: revert.details || revert.shortMessage || 'execution reverted' }
300
+ return { reverted: false, error: [e?.shortMessage, e?.details].filter(Boolean).join(' — ') || e?.message || String(e) }
259
301
  }
260
302
 
261
303
  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, hasQueued } from './notes.js'
9
10
 
10
11
  const args = process.argv.slice(2)
11
12
  const cmd = args[0]
@@ -36,6 +37,25 @@ const parsePct = (raw) => {
36
37
  return n
37
38
  }
38
39
  const WATCH_EVENTS = ['trade', 'progress', 'graduated', 'new_token', 'near_grad']
40
+ // native BNB kept back when wrapping for a V2 buy: gas for the wrap, approve and buy txs (well above BSC's usual cost)
41
+ const GAS_RESERVE = parseEther('0.002')
42
+
43
+ // --note is validated before any RPC / preview, so a bad note never reaches calldata
44
+ const parseNote = () => {
45
+ if (!flag('note')) {
46
+ if (flag('conf')) fail('--conf only applies together with --note')
47
+ if (flag('reveal-after')) fail('--reveal-after only applies together with --note')
48
+ return null
49
+ }
50
+ const raw = opt('note')
51
+ // a value starting with "--" is almost always the next flag (`--note --conf 80`), so it's refused rather than published
52
+ if (raw == null || raw.startsWith('--')) {
53
+ fail('--note needs text right after it (and the text can\'t start with "--"), e.g. --note "volume doubled in 1h, buying the breakout"')
54
+ }
55
+ if (flag('conf') && opt('conf') == null) fail('--conf needs a value 0–100')
56
+ if (flag('reveal-after') && opt('reveal-after') == null) fail('--reveal-after needs whole hours 1–72, e.g. 6h')
57
+ try { return makeNote(raw, opt('conf'), opt('reveal-after')) } catch (e) { fail(e.message) }
58
+ }
39
59
 
40
60
  const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet) · LaunchpadV2 create
41
61
 
@@ -50,16 +70,22 @@ const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet
50
70
  no addr: new_token / near_grad / graduated across the market
51
71
  gang token --token <addr> one token (auto-resolves pad)
52
72
  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]
73
+ gang buy --token <addr> --bnb <n> [--slippage 1] [--note "<why>" [--conf 0-100] [--reveal-after 1-72h]]
74
+ Soft: payable BNB; V2: wrap BNB→WBNB if short, approve quote, then buy
75
+ gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1] [--note … [--conf …] [--reveal-after …]]
55
76
  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…]
77
+ gang create --name <n> --symbol <s> [--logo-uri URL | --logo <file>] [--taxed] [--quote 0x… --ref-pool 0x…] [--note … [--conf …] [--reveal-after …]]
57
78
  LaunchpadV2 ONLY (${ADDR.v2}); Soft/U/S create refused
58
79
  gang execute --request-id <id> broadcast a previewed call
59
- gang tx --hash <0x..> wait for receipt and decode Gang events
80
+ gang tx --hash <0x..> wait for receipt and decode Gang events (publishes a queued --note)
81
+
82
+ --note is PUBLIC: shown on gang.fun next to the trade. Its hash goes into the tx calldata, so it is provably written
83
+ before the trade and can't be edited later. 1–280 chars, no links. Sent by \`gang execute\` (or \`gang tx\`) within 24h.
84
+ --reveal-after 6h seals it: gang.fun holds the text and shows only "a sealed note" until 6h after the trade, then
85
+ publishes it automatically. Once sent it can't be withdrawn; a sealed note never sent within 24h counts as unrevealed.
60
86
 
61
87
  Logo UI (browser): ${LOGO_UI}
62
- env: GANG_LAUNCHPAD (default V2 ${ADDR.v2}), GANG_RPC_URL, GANG_LOGO_API, GANG_API (default ${API})`
88
+ env: GANG_LAUNCHPAD (default V2 ${ADDR.v2}), GANG_RPC_URL, GANG_LOGO_API, GANG_API (default ${API}), GANG_NOTES_DIR (default ~/.gang/notes)`
63
89
 
64
90
  function summarizePreview(p, extra = {}) {
65
91
  if (!p.success) return { success: false, step: extra.step, error: p.error }
@@ -75,6 +101,69 @@ function summarizePreview(p, extra = {}) {
75
101
  }
76
102
  }
77
103
 
104
+ /** Preview with the note's commitment already in `data`; queue the reveal under the preview's requestId. */
105
+ function summarizeWithNote(p, extra, note, kind) {
106
+ const s = summarizePreview(p, extra)
107
+ if (note && s.success) {
108
+ queueNote(note, { kind, token: extra.token ?? null, requestId: s.requestId })
109
+ s.agentNote = {
110
+ attached: true, hash: note.hash, text: note.text, conf: note.conf,
111
+ ...(note.revealAfter
112
+ ? { revealAfterHours: note.revealAfter / 3600, visibility: `sealed: public ${note.revealAfter / 3600}h after the trade` }
113
+ : { visibility: 'public after execute' }),
114
+ }
115
+ }
116
+ return s
117
+ }
118
+ const noteSkipped = (note) =>
119
+ note ? { agentNote: { attached: false, reason: 'this setup step carries no note; pass --note again on the re-run' } } : {}
120
+
121
+ /** Reveal after the tx is mined (the server checks receipt status). Failures don't affect the trade. */
122
+ async function revealNote(entry, hash) {
123
+ let receipt
124
+ try {
125
+ receipt = await client.waitForTransactionReceipt({ hash, timeout: 60_000 })
126
+ } catch (e) {
127
+ // viem keeps polling through RPC errors, so an unreachable RPC also ends up as the 60s timeout
128
+ const timedOut = e.name === 'WaitForTransactionReceiptTimeoutError'
129
+ return {
130
+ published: false, error: timedOut ? 'receipt_timeout' : 'rpc_error',
131
+ detail: timedOut ? 'no receipt within 60s (not mined yet, or RPC unreachable)' : e.shortMessage || e.message.split('\n')[0],
132
+ retry: `gang tx --hash ${hash}`,
133
+ }
134
+ }
135
+ if (receipt.status !== 'success') return { published: false, error: 'tx_failed' }
136
+ return withRetry(await publishNote(entry, hash), hash)
137
+ }
138
+ // only transient failures are worth a retry (network, 5xx incl. rpc_unavailable, rate_limited, tx_not_found);
139
+ // conflict / hash_mismatch / reveal_expired / bad_* are final
140
+ const withRetry = (r, hash) =>
141
+ !r.published && (!r.httpStatus || r.httpStatus >= 500 || r.httpStatus === 429 || r.error === 'tx_not_found')
142
+ ? { ...r, retry: `gang tx --hash ${hash}` } : r
143
+
144
+ /** --quote / --ref-pool: optional addresses, 0x0 when omitted. Mixed-case input must carry a valid checksum. */
145
+ function createAddr(name) {
146
+ if (!flag(name)) return zeroAddress
147
+ const v = opt(name)
148
+ if (!isAddress(v ?? '')) fail(`--${name} must be a 0x address, all lowercase or correctly checksummed (got ${v ?? 'nothing'})`)
149
+ return v
150
+ }
151
+
152
+ const CREATE_HINTS = {
153
+ NotAgent: 'this wallet holds no Agent ID (.ai)',
154
+ RefPoolRequired: 'a non-WBNB --quote needs --ref-pool: a Pancake V2 pair or V3 pool that holds the quote token',
155
+ QuoteNotInPool: '--ref-pool does not contain the --quote token',
156
+ PoolNotFactory: '--ref-pool is not a PancakeSwap V2 pair / V3 pool',
157
+ NotContract: '--ref-pool is not a contract',
158
+ UnknownPoolType: '--ref-pool is not a PancakeSwap V2 pair / V3 pool',
159
+ ThinReferencePool: '--ref-pool is too shallow; pick the deepest pool for this quote',
160
+ PoolNoLiquidity: '--ref-pool has no liquidity',
161
+ NoBridgePool: "the pool's other side has no BNB bridge configured; use a pool paired with WBNB",
162
+ BridgeNotWbnb: "the pool's other side has no BNB bridge configured; use a pool paired with WBNB",
163
+ GradAmtTooSmall: 'the quote token is priced so high that the graduation amount rounds below the minimum',
164
+ GradAmtZero: 'the quote token is priced so high that the graduation amount rounds below the minimum',
165
+ }
166
+
78
167
  async function resolveLogoUri() {
79
168
  const uri = opt('logo-uri')
80
169
  const file = opt('logo') || opt('logo-file') || opt('file-logo')
@@ -212,6 +301,7 @@ async function main() {
212
301
  case 'buy': {
213
302
  const token = opt('token'), bnb = opt('bnb'); if (!token || !bnb) fail('--token and --bnb required')
214
303
  const slippage = parseSlippage(opt('slippage', '1'))
304
+ const note = parseNote()
215
305
  const me = bawAddress()
216
306
  const info = await tokenInfo(token, me)
217
307
  if (info.graduated) fail('token already graduated; trade it on PancakeSwap', { pair: info.pair })
@@ -222,35 +312,59 @@ async function main() {
222
312
  const minOut = (q.tokensOut * BigInt(Math.round((100 - slippage) * 100))) / 10000n
223
313
 
224
314
  if (pad.kind === 'soft') {
225
- const data = calldata.softBuy(token, minOut)
315
+ const data = withNote(calldata.softBuy(token, minOut), note)
226
316
  const p = bawPreview({ from: me, to: pad.address, value: parseEther(bnb), data })
227
- return out(summarizePreview(p, {
317
+ return out(summarizeWithNote(p, {
228
318
  step: 'buy', kind: 'soft', token, symbol: info.symbol, pad: pad.id,
229
319
  bnbSent: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt,
230
320
  expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut),
231
- }))
321
+ }, note, 'buy'))
232
322
  }
233
323
 
234
- // V2 / U / S: quote ERC-20 approve then buy(token, quoteIn, minTokensOut)
324
+ // V2 / U / S: buy(token, quoteIn, minTokensOut) pulls quote ERC-20, so: enough quote → approve → buy
235
325
  const quoteIn = parseEther(bnb)
236
- const allow = await allowance(info.quote, me, pad.address)
326
+ const [have, allow] = await Promise.all([balanceOf(info.quote, me), allowance(info.quote, me, pad.address)])
327
+ if (have < quoteIn) {
328
+ const short = quoteIn - have
329
+ if (info.quote.toLowerCase() !== ADDR.wbnb.toLowerCase()) {
330
+ fail('not enough quote token for this buy (V2 pays in the quote ERC-20, not BNB)', {
331
+ quote: info.quote, need: fmt(quoteIn), have: fmt(have),
332
+ })
333
+ }
334
+ // WBNB-quoted: wrap the shortfall from native BNB, keeping enough native BNB for wrap + approve + buy gas
335
+ const native = await client.getBalance({ address: me })
336
+ if (native < short + GAS_RESERVE) {
337
+ fail('not enough BNB + WBNB for this buy (native BNB must also cover gas)', {
338
+ needWbnb: fmt(quoteIn), haveWbnb: fmt(have), haveBnb: fmt(native), reserveForGas: fmt(GAS_RESERVE),
339
+ })
340
+ }
341
+ const p = bawPreview({ from: me, to: info.quote, value: short, data: calldata.wrap() })
342
+ return out(summarizePreview(p, {
343
+ step: 'wrap-bnb', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
344
+ wrap: fmt(short), haveWbnb: fmt(have), needWbnb: fmt(quoteIn),
345
+ note: 'V2 buys pay in WBNB. Wrap BNB → WBNB first. After it confirms, re-run the same `gang buy` (approve, then the buy).',
346
+ ...noteSkipped(note),
347
+ }))
348
+ }
237
349
  if (allow < quoteIn) {
238
350
  const p = bawPreview({ from: me, to: info.quote, data: calldata.approve(pad.address, quoteIn) })
239
351
  return out(summarizePreview(p, {
240
352
  step: 'approve-quote', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
241
353
  amount: bnb, note: 'Approve quote ERC-20 first. After it confirms, re-run the same `gang buy` to preview the buy.',
354
+ ...noteSkipped(note),
242
355
  }))
243
356
  }
244
- const data = calldata.v2Buy(token, quoteIn, minOut)
357
+ const data = withNote(calldata.v2Buy(token, quoteIn, minOut), note)
245
358
  const p = bawPreview({ from: me, to: pad.address, data })
246
- return out(summarizePreview(p, {
359
+ return out(summarizeWithNote(p, {
247
360
  step: 'buy', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
248
361
  quoteIn: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt,
249
362
  expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut), quotePath: q.quotePath, deterministic: q.deterministic || false,
250
- }))
363
+ }, note, 'buy'))
251
364
  }
252
365
  case 'sell': {
253
366
  const token = opt('token'); if (!token) fail('--token required')
367
+ const note = parseNote()
254
368
  const me = bawAddress()
255
369
  const info = await tokenInfo(token, me)
256
370
  if (info.graduated) fail('token already graduated; sell on PancakeSwap', { pair: info.pair })
@@ -267,23 +381,24 @@ async function main() {
267
381
  return out(summarizePreview(p, {
268
382
  step: 'approve', token, symbol: info.symbol, pad: pad.id, amount: fmtTok(amount),
269
383
  note: 'Approve first. After it confirms on-chain, run the same `gang sell` again to preview the sell.',
384
+ ...noteSkipped(note),
270
385
  }))
271
386
  }
272
387
  const slippage = parseSlippage(opt('slippage', '1'))
273
388
  const q = await quoteSell(token, amount)
274
389
  const minOut = (q.out * BigInt(Math.round((100 - slippage) * 100))) / 10000n
275
390
  if (pad.kind === 'soft') {
276
- const p = bawPreview({ from: me, to: pad.address, data: calldata.softSell(token, amount, minOut) })
277
- return out(summarizePreview(p, {
391
+ const p = bawPreview({ from: me, to: pad.address, data: withNote(calldata.softSell(token, amount, minOut), note) })
392
+ return out(summarizeWithNote(p, {
278
393
  step: 'sell', kind: 'soft', token, symbol: info.symbol, pad: pad.id,
279
394
  amount: fmtTok(amount), expectOut: q.outFmt, fee: q.feeFmt, minOut: fmt(minOut), unit: 'BNB',
280
- }))
395
+ }, note, 'sell'))
281
396
  }
282
- const p = bawPreview({ from: me, to: pad.address, data: calldata.v2Sell(token, amount, minOut) })
283
- return out(summarizePreview(p, {
397
+ const p = bawPreview({ from: me, to: pad.address, data: withNote(calldata.v2Sell(token, amount, minOut), note) })
398
+ return out(summarizeWithNote(p, {
284
399
  step: 'sell', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
285
400
  amount: fmtTok(amount), expectOut: q.outFmt, fee: q.feeFmt, minOut: fmt(minOut), unit: 'quote', quotePath: q.quotePath, deterministic: q.deterministic || false,
286
- }))
401
+ }, note, 'sell'))
287
402
  }
288
403
  case 'upload-logo': {
289
404
  const file = opt('file') || opt('path') || opt('logo')
@@ -294,22 +409,32 @@ async function main() {
294
409
  case 'create': {
295
410
  const name = opt('name'), symbol = opt('symbol'); if (!name || !symbol) fail('--name and --symbol required')
296
411
  try { assertCreateTarget(CONFIG.launchpad) } catch (e) { fail(e.message) }
412
+ const note = parseNote()
413
+ // the old 5-arg --pair-or-quote never matched the contract
414
+ if (flag('pair-or-quote') || flag('pair')) {
415
+ 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.')
416
+ }
417
+ const taxed = flag('taxed')
418
+ const quote = createAddr('quote'), refPool = createAddr('ref-pool')
419
+ const wbnbQuote = [zeroAddress, ADDR.wbnb.toLowerCase()].includes(quote.toLowerCase())
297
420
  const me = bawAddress()
298
421
  if (!(await isAgent(me))) fail('this wallet holds no Agent ID (.ai); create is gated', { address: me })
422
+ // dry run before any logo upload; logoURI and the note suffix can't change whether create reverts
423
+ const dry = await preflightCreate(me, calldata.createV2(name, symbol, '', taxed, quote, refPool), quote, refPool)
424
+ if (!dry.ok && dry.reverted) fail(`create would revert: ${dry.error}`, { quote, refPool, hint: CREATE_HINTS[dry.error] })
425
+ 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
426
  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)
427
+ const data = withNote(calldata.createV2(name, symbol, logo.logoURI, taxed, quote, refPool), note)
306
428
  const p = bawPreview({ from: me, to: ADDR.v2, data })
307
- return out(summarizePreview(p, {
429
+ return out(summarizeWithNote(p, {
308
430
  step: 'create', pad: 'v2', launchpad: ADDR.v2, name, symbol,
309
431
  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
- }))
432
+ taxed, quote: wbnbQuote ? 'WBNB' : quote,
433
+ // the contract ignores refPool for WBNB (Created.refPair = 0x0)
434
+ refPool: wbnbQuote && refPool !== zeroAddress ? `${refPool} (ignored for WBNB)` : refPool,
435
+ graduationAmount: dry.gradAmt, tokenIfNext: dry.tokenIfNext,
436
+ 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.',
437
+ }, note, 'launch'))
313
438
  }
314
439
  case 'execute': {
315
440
  const id = opt('request-id'); if (!id) fail('--request-id required')
@@ -317,8 +442,18 @@ async function main() {
317
442
  if (!r.success) return out({ success: false, error: r.error })
318
443
  const d = r.data
319
444
  const pending = d.status === 'PENDING_CONFIRMATION'
445
+ const entry = findByRequestId(id)
446
+ let note
447
+ if (entry && d.txHash && !pending) note = await revealNote(entry, d.txHash)
448
+ else if (entry && pending) {
449
+ note = { published: false, pending: true, retry: 'after the user approves in the Binance App: `gang tx --hash <hash>` publishes the note' }
450
+ } else if (entry) {
451
+ // any other status without a hash: nothing was broadcast, so there's nothing to reveal (and nothing to wait for)
452
+ note = { published: false, error: 'no_tx_hash', status: d.status }
453
+ }
320
454
  return out({
321
455
  success: true, status: d.status, txHash: d.txHash, orderId: d.orderId, pendingConfirmation: pending,
456
+ ...(note && { note }),
322
457
  next: pending
323
458
  ? '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
459
  : (d.txHash ? `gang tx --hash ${d.txHash}` : undefined),
@@ -326,7 +461,26 @@ async function main() {
326
461
  }
327
462
  case 'tx': {
328
463
  const hash = opt('hash'); if (!hash) fail('--hash required')
329
- return out({ success: true, ...(await decodeTx(hash)) })
464
+ const t = await decodeTx(hash)
465
+ // a note committed in this tx is sent here if it's still in the local queue (e.g. after PENDING_CONFIRMATION),
466
+ // or its state reported / refreshed if already sent; with an empty queue skip the extra RPC read
467
+ let note
468
+ if (hasQueued()) {
469
+ try {
470
+ const committed = hashesIn((await client.getTransaction({ hash })).input)
471
+ if (committed.length) {
472
+ const entry = findByHashes(committed)
473
+ if (!entry) {
474
+ 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' }
475
+ }
476
+ else if (t.status !== 'success') note = { published: false, error: 'tx_failed' }
477
+ else note = withRetry(await publishNote(entry, hash), hash)
478
+ }
479
+ } catch (e) {
480
+ note = { published: false, error: `could not read tx input: ${e.message.split('\n')[0]}` }
481
+ }
482
+ }
483
+ return out({ success: true, ...t, ...(note && { note }) })
330
484
  }
331
485
  default:
332
486
  console.log(HELP); process.exit(cmd ? 1 : 0)
package/src/notes.js ADDED
@@ -0,0 +1,214 @@
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
+ * v1 suffix = "gang" 0x01 keccak256(UTF-8("gang-note:v1\n" salt "\n" conf "\n" text)) (37 bytes)
5
+ * v2 suffix = "gang" 0x02 uint32be(revealAfter) keccak256(UTF-8("gang-note:v2\n" salt "\n" conf "\n" revealAfter "\n" text)) (41 bytes)
6
+ * The text is sent after the tx lands (POST /api/notes), so anyone can check it was written before the trade.
7
+ * v2 (sealed): the server holds the text and only shows it from blockTime + revealAfter.
8
+ * Normalization and the commitment format must match the server byte for byte.
9
+ */
10
+ import { randomBytes } from 'node:crypto'
11
+ import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import { keccak256, toHex } from 'viem'
15
+ import { API } from './discover.js'
16
+
17
+ export const MAGIC = '67616e6701' // "gang" + version 1
18
+ export const MAGIC_V2 = '67616e6702' // "gang" + version 2 (sealed)
19
+ const MAX_CP = 280
20
+ const REVEAL_MIN_H = 1, REVEAL_MAX_H = 72
21
+ const LINK = /https?:\/\/|www\.|t\.me\//i
22
+ // zero-width + bidi controls, and every C0/C1 control except the whitespace ones (\t \n \v \f \r become a space below)
23
+ const STRIP = /[\u200B-\u200D\uFEFF\u202A-\u202E\u2066-\u2069]|(?![\t\n\v\f\r])\p{Cc}/gu
24
+ const TTL_MS = 24 * 3600_000 // the server takes a note (v1 or v2) for 24h after the trade
25
+ const DIR = process.env.GANG_NOTES_DIR || join(homedir(), '.gang', 'notes')
26
+ const ENTRY = /^0x[0-9a-f]{64}\.json$/
27
+ const TMP = /^0x[0-9a-f]{64}\.json\.\d+\.tmp$/ // left behind only if a process died between write and rename
28
+
29
+ /** NFC → strip invisibles/controls → collapse whitespace → trim. Throws with the server's error code in the message. */
30
+ export function normalizeNote(raw) {
31
+ const text = String(raw ?? '').normalize('NFC').replace(STRIP, '').replace(/\s+/gu, ' ').trim()
32
+ const n = [...text].length
33
+ if (n < 1 || n > MAX_CP) throw new Error(`bad_text: note must be 1–${MAX_CP} characters after normalization (got ${n})`)
34
+ if (LINK.test(text)) throw new Error('link_not_allowed: note must not contain links (http://, www., t.me/)')
35
+ return text
36
+ }
37
+
38
+ /** conf is optional; null serializes as '' in the preimage. */
39
+ export function parseConf(raw) {
40
+ if (raw == null) return null
41
+ if (!/^\d{1,3}$/.test(String(raw)) || Number(raw) > 100) throw new Error(`bad_conf: --conf must be an integer 0–100 (got ${raw})`)
42
+ return Number(raw)
43
+ }
44
+
45
+ /** --reveal-after: whole hours 1–72, as `6h` or `6`; returns seconds. null when not sealed. */
46
+ export function parseRevealAfter(raw) {
47
+ if (raw == null) return null
48
+ const m = /^(\d{1,2})h?$/i.exec(String(raw).trim())
49
+ const h = m ? Number(m[1]) : NaN
50
+ if (!(h >= REVEAL_MIN_H && h <= REVEAL_MAX_H)) {
51
+ throw new Error(`bad_reveal_after: --reveal-after must be whole hours ${REVEAL_MIN_H}–${REVEAL_MAX_H}, e.g. 6h (got ${raw})`)
52
+ }
53
+ return h * 3600
54
+ }
55
+
56
+ export const noteHash = ({ salt, conf, text, revealAfter }) =>
57
+ keccak256(toHex(revealAfter
58
+ ? `gang-note:v2\n${salt}\n${conf ?? ''}\n${revealAfter}\n${text}`
59
+ : `gang-note:v1\n${salt}\n${conf ?? ''}\n${text}`))
60
+
61
+ export function makeNote(rawText, rawConf, rawRevealAfter) {
62
+ const text = normalizeNote(rawText)
63
+ const conf = parseConf(rawConf)
64
+ const revealAfter = parseRevealAfter(rawRevealAfter)
65
+ const salt = randomBytes(16).toString('hex')
66
+ const hash = noteHash({ salt, conf, text, revealAfter })
67
+ const suffix = revealAfter
68
+ ? MAGIC_V2 + revealAfter.toString(16).padStart(8, '0') + hash.slice(2)
69
+ : MAGIC + hash.slice(2)
70
+ return { text, conf, revealAfter, salt, hash, suffix }
71
+ }
72
+
73
+ export const withNote = (data, note) => (note ? data + note.suffix : data)
74
+
75
+ /** Commitment hashes (v1 and v2) found in a tx input (contains-match, same as the server: the call may be wrapped). */
76
+ export function hashesIn(input) {
77
+ const s = String(input || '').toLowerCase()
78
+ return [...s.matchAll(/67616e67(?:01|02[0-9a-f]{8})([0-9a-f]{64})/g)].map((m) => `0x${m[1]}`)
79
+ }
80
+
81
+ // ---- local queue: one file per note, <dir>/<hash>.json → { salt, conf, text, revealAfter, kind, token, requestId,
82
+ // createdAt, publishedTx, publishedStatus, revealAt }. Kept 24h (the server's submit window), so a failed send
83
+ // can still be retried (`gang tx --hash`). Each process only ever writes its own note's file (atomic rename), so concurrent
84
+ // `gang` commands need no lock and a damaged file can't take the other notes' salts down with it.
85
+
86
+ const pathOf = (hash) => join(DIR, `${hash}.json`)
87
+
88
+ function read(hash) {
89
+ try { return JSON.parse(readFileSync(pathOf(hash), 'utf8')) } catch { return null }
90
+ }
91
+
92
+ function write(hash, entry) {
93
+ mkdirSync(DIR, { recursive: true, mode: 0o700 })
94
+ const tmp = `${pathOf(hash)}.${process.pid}.tmp`
95
+ writeFileSync(tmp, JSON.stringify(entry, null, 2), { mode: 0o600 })
96
+ renameSync(tmp, pathOf(hash))
97
+ }
98
+
99
+ const fresh = (e) => e?.createdAt > Date.now() - TTL_MS
100
+
101
+ const old = (path) => Date.now() - statSync(path).mtimeMs > TTL_MS
102
+
103
+ /** Live entries; entries, unreadable files and stray temp files past the 24h reveal window are deleted. */
104
+ function entries() {
105
+ let names
106
+ try { names = readdirSync(DIR) } catch { return [] }
107
+ const live = []
108
+ for (const name of names) {
109
+ const path = join(DIR, name)
110
+ if (TMP.test(name)) {
111
+ try { if (old(path)) unlinkSync(path) } catch { /* already gone */ }
112
+ continue
113
+ }
114
+ if (!ENTRY.test(name)) continue
115
+ const hash = name.slice(0, -5)
116
+ const e = read(hash)
117
+ if (fresh(e)) { live.push({ hash, ...e }); continue }
118
+ try {
119
+ // an unreadable file (e.g. damaged by hand) is kept for 24h so its salt can still be recovered manually
120
+ if (e || old(path)) unlinkSync(path)
121
+ } catch { /* already gone */ }
122
+ }
123
+ return live
124
+ }
125
+
126
+ export function queueNote(note, meta) {
127
+ write(note.hash, {
128
+ salt: note.salt, conf: note.conf, text: note.text, revealAfter: note.revealAfter, ...meta, createdAt: Date.now(), publishedTx: null,
129
+ })
130
+ }
131
+
132
+ /** Any live entry, sent or not: `gang tx` also refreshes a 'pending' one and reports a sealed one's revealAt. */
133
+ export const hasQueued = () => entries().length > 0
134
+
135
+ export const findByRequestId = (requestId) => entries().find((e) => e.requestId === requestId) ?? null
136
+
137
+ export function findByHashes(hashes) {
138
+ for (const hash of hashes) {
139
+ const e = read(hash)
140
+ if (fresh(e)) return { hash, ...e }
141
+ }
142
+ return null
143
+ }
144
+
145
+ function markPublished(hash, tx, status, revealAt) {
146
+ const e = read(hash)
147
+ if (e) write(hash, { ...e, publishedTx: tx, publishedStatus: status, revealAt })
148
+ }
149
+
150
+ /** revealAt as epoch ms, whatever the server sends (unix seconds, ms or an ISO string); null if unknown. */
151
+ function revealMs(v) {
152
+ if (v == null || v === '') return null
153
+ const n = typeof v === 'number' ? v : /^\d+$/.test(String(v)) ? Number(v) : Date.parse(v)
154
+ if (!Number.isFinite(n)) return null
155
+ return n < 1e12 ? n * 1000 : n
156
+ }
157
+
158
+ /**
159
+ * Shape of a successful send: a v2 note not yet public says when it will be (revealAt can be null while 'pending').
160
+ * A stored 'sealed' whose revealAt has passed is reported as 'ok': the server shows it by itself from then on.
161
+ */
162
+ function sent(entry, status, revealAt, extra = {}) {
163
+ const ms = revealMs(revealAt)
164
+ if (status === 'sealed' && ms != null && ms <= Date.now()) status = 'ok'
165
+ if (status === 'sealed' || (entry.revealAfter && status === 'pending')) {
166
+ return { published: true, status, sealed: true, revealAt, revealAtIso: ms == null ? null : new Date(ms).toISOString(), ...extra }
167
+ }
168
+ return { published: true, status, ...extra }
169
+ }
170
+
171
+ /**
172
+ * Send a queued note for a landed tx. Never throws: the trade already happened, so a failed send is
173
+ * reported (and stays queued for `gang tx --hash` to retry) instead of failing the command.
174
+ * Sealed (v2) notes are sent right away too: the server keeps the text hidden until revealAt.
175
+ */
176
+ export async function publishNote(entry, tx) {
177
+ if (entry.publishedTx !== tx) return post(entry, tx)
178
+ const stored = sent(entry, entry.publishedStatus || 'ok', entry.revealAt ?? null, { already: true })
179
+ if (entry.publishedStatus !== 'pending') return stored
180
+ // sent while the indexer hadn't caught up: re-send (same content → idempotent 200) to refresh the status and a
181
+ // sealed note's revealAt; if that fails the note is still sent, so report the stored state rather than a failure
182
+ const r = await post(entry, tx)
183
+ return r.published ? { ...r, already: true } : stored
184
+ }
185
+
186
+ async function post(entry, tx) {
187
+ const ctl = new AbortController()
188
+ const timer = setTimeout(() => ctl.abort(), 10_000)
189
+ try {
190
+ const r = await fetch(`${API}/notes`, {
191
+ method: 'POST', signal: ctl.signal,
192
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
193
+ body: JSON.stringify({
194
+ tx, salt: entry.salt, conf: entry.conf, text: entry.text, ...(entry.revealAfter && { revealAfter: entry.revealAfter }),
195
+ }),
196
+ })
197
+ if (r.status === 200 || r.status === 202) {
198
+ let body
199
+ try { body = await r.json() } catch { /* status code is enough */ }
200
+ // ok | hidden (matched the blocklist, not shown) | pending (server waits for the indexer) | sealed (v2, shown from revealAt)
201
+ const status = body?.status || (r.status === 202 ? 'pending' : 'ok')
202
+ const revealAt = body?.revealAt ?? null
203
+ try { markPublished(entry.hash, tx, status, revealAt) } catch { /* sent anyway; a later `gang tx` just gets 200 again */ }
204
+ return sent(entry, status, revealAt)
205
+ }
206
+ let code
207
+ try { code = (await r.json())?.error } catch { /* non-JSON error body */ }
208
+ return { published: false, error: code || `http_${r.status}`, httpStatus: r.status }
209
+ } catch (e) {
210
+ return { published: false, error: `unreachable: ${ctl.signal.aborted ? 'timed out after 10s' : e.message}` }
211
+ } finally {
212
+ clearTimeout(timer)
213
+ }
214
+ }