@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 +23 -6
- package/README.zh-CN.md +24 -5
- package/package.json +1 -1
- package/src/abi.js +10 -1
- package/src/chain.js +47 -5
- package/src/index.js +185 -31
- package/src/notes.js +214 -0
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] [--
|
|
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`
|
|
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.
|
|
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] [--
|
|
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` |
|
|
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.
|
|
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
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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]
|
|
54
|
-
|
|
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] [--
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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,
|
|
311
|
-
|
|
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
|
-
|
|
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
|
+
}
|