@gangfun/cli 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -6
- package/README.zh-CN.md +21 -5
- package/package.json +1 -1
- package/src/abi.js +5 -1
- package/src/chain.js +45 -4
- package/src/index.js +150 -29
- package/src/notes.js +155 -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]]
|
|
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,17 @@ Without it, it emits `new_token` / `near_grad` (crosses `--min-pct`) / `graduate
|
|
|
87
89
|
gang watch --events new_token,near_grad --max-events 1 --timeout 30m
|
|
88
90
|
```
|
|
89
91
|
|
|
92
|
+
### Agent notes (`--note`)
|
|
93
|
+
|
|
94
|
+
`--note "<why>" [--conf 0-100]` on buy / sell / create attaches a **public** reason, shown on gang.fun next to the trade.
|
|
95
|
+
Its hash is appended to the tx calldata (37 bytes; the contract ignores them), so the note is provably written before the trade and can't be edited.
|
|
96
|
+
1–280 characters, no links. `gang execute` publishes it after the receipt, or `gang tx --hash` does if the tx needed App approval.
|
|
97
|
+
A failed publish never affects the trade. Details: [skill/references/notes.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/notes.md).
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
gang buy --token 0x… --bnb 0.1 --note "volume doubled in 1h, buying the breakout" --conf 70
|
|
101
|
+
```
|
|
102
|
+
|
|
90
103
|
## Quote path
|
|
91
104
|
|
|
92
105
|
- **Soft archive tokens:** on-chain `quoteBuy` / `quoteSell`.
|
|
@@ -107,8 +120,9 @@ gang watch --events new_token,near_grad --max-events 1 --timeout 30m
|
|
|
107
120
|
| `GANG_LAUNCHPAD` | Create target (default LaunchpadV2 `0x1B8F3f69…966E`) |
|
|
108
121
|
| `GANG_RPC_URL` | BSC HTTP RPC |
|
|
109
122
|
| `GANG_LOGO_API` | Logo upload endpoint (default `https://gang.fun/api/logos`) |
|
|
110
|
-
| `GANG_API` | Indexer API for discovery (default `https://gang.fun/api`) |
|
|
123
|
+
| `GANG_API` | Indexer API for discovery and note publishing (default `https://gang.fun/api`) |
|
|
124
|
+
| `GANG_NOTES_DIR` | Local queue of unpublished notes, one file per note (default `~/.gang/notes`) |
|
|
111
125
|
|
|
112
126
|
## Version
|
|
113
127
|
|
|
114
|
-
`0.3.
|
|
128
|
+
`0.3.1` · repo [gangfun/cli](https://github.com/gangfun/cli) · published to [npm](https://www.npmjs.com/package/@gangfun/cli) on push to `main`
|
package/README.zh-CN.md
CHANGED
|
@@ -35,10 +35,10 @@ gang near-grad [--min-pct 50] [--limit 10]
|
|
|
35
35
|
gang watch [<addr>] [--interval 10] [--events a,b] [--max-events N] [--timeout 30m] [--webhook URL]
|
|
36
36
|
gang token --token <addr>
|
|
37
37
|
gang quote --token <addr> --bnb <n>
|
|
38
|
-
gang buy --token <addr> --bnb <n> [--slippage 1]
|
|
39
|
-
gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1]
|
|
38
|
+
gang buy --token <addr> --bnb <n> [--slippage 1] [--note "<理由>" [--conf 0-100]]
|
|
39
|
+
gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1] [--note … [--conf …]]
|
|
40
40
|
gang upload-logo --file ./avatar.png
|
|
41
|
-
gang create --name <n> --symbol <s> [--logo-uri URL | --logo ./avatar.png] [--taxed] [--
|
|
41
|
+
gang create --name <n> --symbol <s> [--logo-uri URL | --logo ./avatar.png] [--taxed] [--quote 0x… --ref-pool 0x…] [--note …]
|
|
42
42
|
gang execute --request-id <id>
|
|
43
43
|
gang tx --hash <0x..>
|
|
44
44
|
```
|
|
@@ -57,6 +57,10 @@ gang create --name "My Agent Coin" --symbol MAC --logo ./avatar.png
|
|
|
57
57
|
gang execute --request-id …
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
默认计价是 WBNB;`--taxed` 不需要池子。用其他代币计价时,加 `--quote <ERC-20> --ref-pool <持有该代币的 Pancake V2 pair / V3 池>`。
|
|
61
|
+
preview 之前 CLI 会先模拟一次 `create`,如果会回滚,就直接报出合约的错误名(`RefPoolRequired`、`ThinReferencePool` 等)。
|
|
62
|
+
`--pair-or-quote` 已移除:0.3.0 及之前的版本编码的是 LaunchpadV2 没有的 5 参数 create。
|
|
63
|
+
|
|
60
64
|
### 发现(0.3.0+)
|
|
61
65
|
|
|
62
66
|
`hot` / `new` / `near-grad` / `watch` 读 gang.fun indexer(`GANG_API`),同时覆盖 V2 与 Soft。每行带 `quote`(`--bnb` 的单位)、
|
|
@@ -65,6 +69,17 @@ gang execute --request-id …
|
|
|
65
69
|
`gang watch` 轮询并输出 NDJSON(最短间隔:单币 3s,全市场 10s):带 `<addr>` 推 `trade` / `progress`(25/50/75/90%)/ `graduated`;不带则推 `new_token` / `near_grad`(越过 `--min-pct`)/ `graduated`。
|
|
66
70
|
`--max-events 1` = 阻塞到下一个事件;`--webhook` 把每个事件 POST 出去。详见 [skill/references/discover.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/discover.md)。
|
|
67
71
|
|
|
72
|
+
### Agent 想法(`--note`)
|
|
73
|
+
|
|
74
|
+
在 buy / sell / create 后面加 `--note "<理由>" [--conf 0-100]`,就能给这笔交易附一句理由。附言**会公开**,显示在 gang.fun 上这笔交易旁边。
|
|
75
|
+
附言的哈希会追加到交易 calldata 末尾(37 字节,合约会忽略),因此可以证明它写在交易之前,事后也改不了。
|
|
76
|
+
附言限 1–280 字,不能含链接。`gang execute` 在拿到回执后自动公开;如果交易需要 App 确认,就在确认后用 `gang tx --hash` 补发。
|
|
77
|
+
公开失败不影响交易本身。详见 [skill/references/notes.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/notes.md)。
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
gang buy --token 0x… --bnb 0.1 --note "1 小时量能翻倍,买突破" --conf 70
|
|
81
|
+
```
|
|
82
|
+
|
|
68
83
|
## Quote 路径
|
|
69
84
|
|
|
70
85
|
- Soft 存量:链上 `quoteBuy` / `quoteSell`。
|
|
@@ -83,8 +98,9 @@ gang execute --request-id …
|
|
|
83
98
|
| `GANG_LAUNCHPAD` | 发币目标(默认 V2) |
|
|
84
99
|
| `GANG_RPC_URL` | BSC RPC |
|
|
85
100
|
| `GANG_LOGO_API` | Logo 上传 API(默认 `https://gang.fun/api/logos`) |
|
|
86
|
-
| `GANG_API` |
|
|
101
|
+
| `GANG_API` | indexer API,发现命令和公开附言都用它(默认 `https://gang.fun/api`) |
|
|
102
|
+
| `GANG_NOTES_DIR` | 未公开附言的本地队列,每条一个文件(默认 `~/.gang/notes`) |
|
|
87
103
|
|
|
88
104
|
## 版本
|
|
89
105
|
|
|
90
|
-
`0.3.
|
|
106
|
+
`0.3.1` · [gangfun/cli](https://github.com/gangfun/cli) · push 到 `main` 自动发布到 [npm](https://www.npmjs.com/package/@gangfun/cli)
|
package/package.json
CHANGED
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 },
|
package/src/chain.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createPublicClient, http, formatEther, parseEther, encodeFunctionData, decodeEventLog, zeroAddress } from 'viem'
|
|
1
|
+
import { createPublicClient, http, formatEther, formatUnits, parseEther, encodeFunctionData, decodeEventLog, decodeErrorResult, isAddress, zeroAddress } from 'viem'
|
|
2
2
|
import { bsc } from 'viem/chains'
|
|
3
3
|
import { SOFT_ABI, V2_ABI, ERC20_ABI } from './abi.js'
|
|
4
4
|
|
|
@@ -21,7 +21,7 @@ export const CONFIG = {
|
|
|
21
21
|
export const client = createPublicClient({ chain: bsc, transport: http(CONFIG.rpc) })
|
|
22
22
|
export const fmt = (wei) => formatEther(wei)
|
|
23
23
|
export const fmtTok = (units) => Number(formatEther(units)).toLocaleString('en-US', { maximumFractionDigits: 2 })
|
|
24
|
-
export { parseEther, encodeFunctionData, zeroAddress }
|
|
24
|
+
export { parseEther, encodeFunctionData, isAddress, zeroAddress }
|
|
25
25
|
|
|
26
26
|
const erc = (address, functionName, args = []) => client.readContract({ address, abi: ERC20_ABI, functionName, args })
|
|
27
27
|
const read = (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args })
|
|
@@ -254,8 +254,49 @@ export const calldata = {
|
|
|
254
254
|
v2Buy: (token, quoteIn, minTokensOut) => encodeFunctionData({ abi: V2_ABI, functionName: 'buy', args: [token, quoteIn, minTokensOut] }),
|
|
255
255
|
v2Sell: (token, tokensIn, minQuoteOut) => encodeFunctionData({ abi: V2_ABI, functionName: 'sell', args: [token, tokensIn, minQuoteOut] }),
|
|
256
256
|
approve: (spender, amount) => encodeFunctionData({ abi: ERC20_ABI, functionName: 'approve', args: [spender, amount] }),
|
|
257
|
-
createV2: (name, symbol, logoURI, taxed,
|
|
258
|
-
encodeFunctionData({ abi: V2_ABI, functionName: 'create', args: [name, symbol, logoURI, taxed,
|
|
257
|
+
createV2: (name, symbol, logoURI, taxed, quote, refPool) =>
|
|
258
|
+
encodeFunctionData({ abi: V2_ABI, functionName: 'create', args: [name, symbol, logoURI, taxed, quote, refPool] }),
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Dry-run a create from the agent wallet (eth_call) before baw preview, so a bad quote / refPool / gate fails
|
|
263
|
+
* here with the contract's error name instead of an opaque preview rejection.
|
|
264
|
+
* → { ok: true, gradAmt, tokenIfNext } | { ok: false, reverted: true, error: <ContractError> } | { ok: false, reverted: false, error }
|
|
265
|
+
* `reverted: false` means the RPC failed, not the create: retrying with the same flags is fine.
|
|
266
|
+
*/
|
|
267
|
+
export async function preflightCreate(from, data, quote, refPool) {
|
|
268
|
+
let gradAmt, r
|
|
269
|
+
try {
|
|
270
|
+
gradAmt = await read(ADDR.v2, V2_ABI, 'graduationAmountOf', [quote, refPool])
|
|
271
|
+
r = await client.call({ account: from, to: ADDR.v2, data })
|
|
272
|
+
} catch (e) {
|
|
273
|
+
return { ok: false, ...callFailure(e) }
|
|
274
|
+
}
|
|
275
|
+
// CREATE address: only right if no other create lands first
|
|
276
|
+
const tokenIfNext = r.data && r.data.length >= 66 ? `0x${r.data.slice(-40)}` : null
|
|
277
|
+
return { ok: true, gradAmt: await fmtQuote(quote, gradAmt), tokenIfNext }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Quote units with the quote's decimals; raw integer if the token has no readable decimals(). */
|
|
281
|
+
async function fmtQuote(quote, amount) {
|
|
282
|
+
if (quote === zeroAddress || quote.toLowerCase() === ADDR.wbnb.toLowerCase()) return formatUnits(amount, 18)
|
|
283
|
+
try { return formatUnits(amount, Number(await erc(quote, 'decimals'))) } catch { return `${amount} (raw units)` }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function callFailure(e) {
|
|
287
|
+
const raw = e?.walk?.((x) => typeof x?.data === 'string' && x.data.startsWith('0x'))?.data
|
|
288
|
+
if (raw && raw.length >= 10) {
|
|
289
|
+
try { return { reverted: true, error: decodeErrorResult({ abi: V2_ABI, data: raw }).errorName } } catch { /* not one of ours */ }
|
|
290
|
+
}
|
|
291
|
+
const named = e?.walk?.((x) => x?.data?.errorName)?.data?.errorName
|
|
292
|
+
if (named) return { reverted: true, error: named }
|
|
293
|
+
// empty revert data: viem only names it ExecutionRevertedError when the node's text matches, so also accept
|
|
294
|
+
// JSON-RPC code 3 (the standard execution-error code) or any "revert" in the chain; everything else is the RPC
|
|
295
|
+
const revert = e?.walk?.((x) => ['ExecutionRevertedError', 'ContractFunctionRevertedError'].includes(x?.name) ||
|
|
296
|
+
x?.code === 3 || /revert/i.test(x?.details || x?.shortMessage || ''))
|
|
297
|
+
// a revert carries the node's own words; the "RPC Request failed." wrapper text would read like a network error
|
|
298
|
+
if (revert) return { reverted: true, error: revert.details || revert.shortMessage || 'execution reverted' }
|
|
299
|
+
return { reverted: false, error: [e?.shortMessage, e?.details].filter(Boolean).join(' — ') || e?.message || String(e) }
|
|
259
300
|
}
|
|
260
301
|
|
|
261
302
|
export async function decodeTx(hash) {
|
package/src/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
CONFIG, ADDR, listTokens, tokenInfo, quoteBuy, quoteSell, isAgent, balanceOf, allowance,
|
|
4
|
-
calldata, decodeTx, parseEther, fmt, fmtTok, resolvePad, assertCreateTarget, zeroAddress,
|
|
4
|
+
calldata, decodeTx, parseEther, fmt, fmtTok, resolvePad, assertCreateTarget, preflightCreate, isAddress, zeroAddress, client,
|
|
5
5
|
} from './chain.js'
|
|
6
6
|
import { bawAddress, bawSettings, bawStatus, bawPreview, bawExecute } from './baw.js'
|
|
7
7
|
import { uploadLogoFile, LOGO_UI } from './logo.js'
|
|
8
8
|
import { API, fetchMarket, rank, watch, parseDuration } from './discover.js'
|
|
9
|
+
import { makeNote, withNote, queueNote, findByRequestId, findByHashes, hashesIn, publishNote, hasUnpublished } from './notes.js'
|
|
9
10
|
|
|
10
11
|
const args = process.argv.slice(2)
|
|
11
12
|
const cmd = args[0]
|
|
@@ -37,6 +38,21 @@ const parsePct = (raw) => {
|
|
|
37
38
|
}
|
|
38
39
|
const WATCH_EVENTS = ['trade', 'progress', 'graduated', 'new_token', 'near_grad']
|
|
39
40
|
|
|
41
|
+
// --note is validated before any RPC / preview, so a bad note never reaches calldata
|
|
42
|
+
const parseNote = () => {
|
|
43
|
+
if (!flag('note')) {
|
|
44
|
+
if (flag('conf')) fail('--conf only applies together with --note')
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
const raw = opt('note')
|
|
48
|
+
// a value starting with "--" is almost always the next flag (`--note --conf 80`), so it's refused rather than published
|
|
49
|
+
if (raw == null || raw.startsWith('--')) {
|
|
50
|
+
fail('--note needs text right after it (and the text can\'t start with "--"), e.g. --note "volume doubled in 1h, buying the breakout"')
|
|
51
|
+
}
|
|
52
|
+
if (flag('conf') && opt('conf') == null) fail('--conf needs a value 0–100')
|
|
53
|
+
try { return makeNote(raw, opt('conf')) } catch (e) { fail(e.message) }
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet) · LaunchpadV2 create
|
|
41
57
|
|
|
42
58
|
gang preflight check sign-in, Developer Mode, tradeAllTokens, Agent ID
|
|
@@ -50,16 +66,20 @@ const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet
|
|
|
50
66
|
no addr: new_token / near_grad / graduated across the market
|
|
51
67
|
gang token --token <addr> one token (auto-resolves pad)
|
|
52
68
|
gang quote --token <addr> --bnb <n> Soft: BNB quote; V2/U/S: quote-token amount (same flag)
|
|
53
|
-
gang buy --token <addr> --bnb <n> [--slippage 1]
|
|
54
|
-
|
|
69
|
+
gang buy --token <addr> --bnb <n> [--slippage 1] [--note "<why>" [--conf 0-100]]
|
|
70
|
+
Soft: payable BNB; V2: approve quote then buy
|
|
71
|
+
gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1] [--note … [--conf …]]
|
|
55
72
|
gang upload-logo --file <path> POST image → https://gang.fun/api/logos → logoUrl
|
|
56
|
-
gang create --name <n> --symbol <s> [--logo-uri URL | --logo <file>] [--taxed] [--
|
|
73
|
+
gang create --name <n> --symbol <s> [--logo-uri URL | --logo <file>] [--taxed] [--quote 0x… --ref-pool 0x…] [--note … [--conf …]]
|
|
57
74
|
LaunchpadV2 ONLY (${ADDR.v2}); Soft/U/S create refused
|
|
58
75
|
gang execute --request-id <id> broadcast a previewed call
|
|
59
|
-
gang tx --hash <0x..> wait for receipt and decode Gang events
|
|
76
|
+
gang tx --hash <0x..> wait for receipt and decode Gang events (publishes a queued --note)
|
|
77
|
+
|
|
78
|
+
--note is PUBLIC: shown on gang.fun next to the trade. Its hash goes into the tx calldata, so it is provably written
|
|
79
|
+
before the trade and can't be edited later. 1–280 chars, no links. Published by \`gang execute\` (or \`gang tx\`) within 24h.
|
|
60
80
|
|
|
61
81
|
Logo UI (browser): ${LOGO_UI}
|
|
62
|
-
env: GANG_LAUNCHPAD (default V2 ${ADDR.v2}), GANG_RPC_URL, GANG_LOGO_API, GANG_API (default ${API})`
|
|
82
|
+
env: GANG_LAUNCHPAD (default V2 ${ADDR.v2}), GANG_RPC_URL, GANG_LOGO_API, GANG_API (default ${API}), GANG_NOTES_DIR (default ~/.gang/notes)`
|
|
63
83
|
|
|
64
84
|
function summarizePreview(p, extra = {}) {
|
|
65
85
|
if (!p.success) return { success: false, step: extra.step, error: p.error }
|
|
@@ -75,6 +95,64 @@ function summarizePreview(p, extra = {}) {
|
|
|
75
95
|
}
|
|
76
96
|
}
|
|
77
97
|
|
|
98
|
+
/** Preview with the note's commitment already in `data`; queue the reveal under the preview's requestId. */
|
|
99
|
+
function summarizeWithNote(p, extra, note, kind) {
|
|
100
|
+
const s = summarizePreview(p, extra)
|
|
101
|
+
if (note && s.success) {
|
|
102
|
+
queueNote(note, { kind, token: extra.token ?? null, requestId: s.requestId })
|
|
103
|
+
s.agentNote = { attached: true, hash: note.hash, text: note.text, conf: note.conf, visibility: 'public after execute' }
|
|
104
|
+
}
|
|
105
|
+
return s
|
|
106
|
+
}
|
|
107
|
+
const noteSkipped = (note) =>
|
|
108
|
+
note ? { agentNote: { attached: false, reason: 'approve step carries no note; pass --note again on the re-run' } } : {}
|
|
109
|
+
|
|
110
|
+
/** Reveal after the tx is mined (the server checks receipt status). Failures don't affect the trade. */
|
|
111
|
+
async function revealNote(entry, hash) {
|
|
112
|
+
let receipt
|
|
113
|
+
try {
|
|
114
|
+
receipt = await client.waitForTransactionReceipt({ hash, timeout: 60_000 })
|
|
115
|
+
} catch (e) {
|
|
116
|
+
// viem keeps polling through RPC errors, so an unreachable RPC also ends up as the 60s timeout
|
|
117
|
+
const timedOut = e.name === 'WaitForTransactionReceiptTimeoutError'
|
|
118
|
+
return {
|
|
119
|
+
published: false, error: timedOut ? 'receipt_timeout' : 'rpc_error',
|
|
120
|
+
detail: timedOut ? 'no receipt within 60s (not mined yet, or RPC unreachable)' : e.shortMessage || e.message.split('\n')[0],
|
|
121
|
+
retry: `gang tx --hash ${hash}`,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (receipt.status !== 'success') return { published: false, error: 'tx_failed' }
|
|
125
|
+
return withRetry(await publishNote(entry, hash), hash)
|
|
126
|
+
}
|
|
127
|
+
// only transient failures are worth a retry (network, 5xx incl. rpc_unavailable, rate_limited, tx_not_found);
|
|
128
|
+
// conflict / hash_mismatch / reveal_expired / bad_* are final
|
|
129
|
+
const withRetry = (r, hash) =>
|
|
130
|
+
!r.published && (!r.httpStatus || r.httpStatus >= 500 || r.httpStatus === 429 || r.error === 'tx_not_found')
|
|
131
|
+
? { ...r, retry: `gang tx --hash ${hash}` } : r
|
|
132
|
+
|
|
133
|
+
/** --quote / --ref-pool: optional addresses, 0x0 when omitted. Mixed-case input must carry a valid checksum. */
|
|
134
|
+
function createAddr(name) {
|
|
135
|
+
if (!flag(name)) return zeroAddress
|
|
136
|
+
const v = opt(name)
|
|
137
|
+
if (!isAddress(v ?? '')) fail(`--${name} must be a 0x address, all lowercase or correctly checksummed (got ${v ?? 'nothing'})`)
|
|
138
|
+
return v
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const CREATE_HINTS = {
|
|
142
|
+
NotAgent: 'this wallet holds no Agent ID (.ai)',
|
|
143
|
+
RefPoolRequired: 'a non-WBNB --quote needs --ref-pool: a Pancake V2 pair or V3 pool that holds the quote token',
|
|
144
|
+
QuoteNotInPool: '--ref-pool does not contain the --quote token',
|
|
145
|
+
PoolNotFactory: '--ref-pool is not a PancakeSwap V2 pair / V3 pool',
|
|
146
|
+
NotContract: '--ref-pool is not a contract',
|
|
147
|
+
UnknownPoolType: '--ref-pool is not a PancakeSwap V2 pair / V3 pool',
|
|
148
|
+
ThinReferencePool: '--ref-pool is too shallow; pick the deepest pool for this quote',
|
|
149
|
+
PoolNoLiquidity: '--ref-pool has no liquidity',
|
|
150
|
+
NoBridgePool: "the pool's other side has no BNB bridge configured; use a pool paired with WBNB",
|
|
151
|
+
BridgeNotWbnb: "the pool's other side has no BNB bridge configured; use a pool paired with WBNB",
|
|
152
|
+
GradAmtTooSmall: 'the quote token is priced so high that the graduation amount rounds below the minimum',
|
|
153
|
+
GradAmtZero: 'the quote token is priced so high that the graduation amount rounds below the minimum',
|
|
154
|
+
}
|
|
155
|
+
|
|
78
156
|
async function resolveLogoUri() {
|
|
79
157
|
const uri = opt('logo-uri')
|
|
80
158
|
const file = opt('logo') || opt('logo-file') || opt('file-logo')
|
|
@@ -212,6 +290,7 @@ async function main() {
|
|
|
212
290
|
case 'buy': {
|
|
213
291
|
const token = opt('token'), bnb = opt('bnb'); if (!token || !bnb) fail('--token and --bnb required')
|
|
214
292
|
const slippage = parseSlippage(opt('slippage', '1'))
|
|
293
|
+
const note = parseNote()
|
|
215
294
|
const me = bawAddress()
|
|
216
295
|
const info = await tokenInfo(token, me)
|
|
217
296
|
if (info.graduated) fail('token already graduated; trade it on PancakeSwap', { pair: info.pair })
|
|
@@ -222,13 +301,13 @@ async function main() {
|
|
|
222
301
|
const minOut = (q.tokensOut * BigInt(Math.round((100 - slippage) * 100))) / 10000n
|
|
223
302
|
|
|
224
303
|
if (pad.kind === 'soft') {
|
|
225
|
-
const data = calldata.softBuy(token, minOut)
|
|
304
|
+
const data = withNote(calldata.softBuy(token, minOut), note)
|
|
226
305
|
const p = bawPreview({ from: me, to: pad.address, value: parseEther(bnb), data })
|
|
227
|
-
return out(
|
|
306
|
+
return out(summarizeWithNote(p, {
|
|
228
307
|
step: 'buy', kind: 'soft', token, symbol: info.symbol, pad: pad.id,
|
|
229
308
|
bnbSent: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt,
|
|
230
309
|
expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut),
|
|
231
|
-
}))
|
|
310
|
+
}, note, 'buy'))
|
|
232
311
|
}
|
|
233
312
|
|
|
234
313
|
// V2 / U / S: quote ERC-20 approve then buy(token, quoteIn, minTokensOut)
|
|
@@ -239,18 +318,20 @@ async function main() {
|
|
|
239
318
|
return out(summarizePreview(p, {
|
|
240
319
|
step: 'approve-quote', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
|
|
241
320
|
amount: bnb, note: 'Approve quote ERC-20 first. After it confirms, re-run the same `gang buy` to preview the buy.',
|
|
321
|
+
...noteSkipped(note),
|
|
242
322
|
}))
|
|
243
323
|
}
|
|
244
|
-
const data = calldata.v2Buy(token, quoteIn, minOut)
|
|
324
|
+
const data = withNote(calldata.v2Buy(token, quoteIn, minOut), note)
|
|
245
325
|
const p = bawPreview({ from: me, to: pad.address, data })
|
|
246
|
-
return out(
|
|
326
|
+
return out(summarizeWithNote(p, {
|
|
247
327
|
step: 'buy', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
|
|
248
328
|
quoteIn: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt,
|
|
249
329
|
expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut), quotePath: q.quotePath, deterministic: q.deterministic || false,
|
|
250
|
-
}))
|
|
330
|
+
}, note, 'buy'))
|
|
251
331
|
}
|
|
252
332
|
case 'sell': {
|
|
253
333
|
const token = opt('token'); if (!token) fail('--token required')
|
|
334
|
+
const note = parseNote()
|
|
254
335
|
const me = bawAddress()
|
|
255
336
|
const info = await tokenInfo(token, me)
|
|
256
337
|
if (info.graduated) fail('token already graduated; sell on PancakeSwap', { pair: info.pair })
|
|
@@ -267,23 +348,24 @@ async function main() {
|
|
|
267
348
|
return out(summarizePreview(p, {
|
|
268
349
|
step: 'approve', token, symbol: info.symbol, pad: pad.id, amount: fmtTok(amount),
|
|
269
350
|
note: 'Approve first. After it confirms on-chain, run the same `gang sell` again to preview the sell.',
|
|
351
|
+
...noteSkipped(note),
|
|
270
352
|
}))
|
|
271
353
|
}
|
|
272
354
|
const slippage = parseSlippage(opt('slippage', '1'))
|
|
273
355
|
const q = await quoteSell(token, amount)
|
|
274
356
|
const minOut = (q.out * BigInt(Math.round((100 - slippage) * 100))) / 10000n
|
|
275
357
|
if (pad.kind === 'soft') {
|
|
276
|
-
const p = bawPreview({ from: me, to: pad.address, data: calldata.softSell(token, amount, minOut) })
|
|
277
|
-
return out(
|
|
358
|
+
const p = bawPreview({ from: me, to: pad.address, data: withNote(calldata.softSell(token, amount, minOut), note) })
|
|
359
|
+
return out(summarizeWithNote(p, {
|
|
278
360
|
step: 'sell', kind: 'soft', token, symbol: info.symbol, pad: pad.id,
|
|
279
361
|
amount: fmtTok(amount), expectOut: q.outFmt, fee: q.feeFmt, minOut: fmt(minOut), unit: 'BNB',
|
|
280
|
-
}))
|
|
362
|
+
}, note, 'sell'))
|
|
281
363
|
}
|
|
282
|
-
const p = bawPreview({ from: me, to: pad.address, data: calldata.v2Sell(token, amount, minOut) })
|
|
283
|
-
return out(
|
|
364
|
+
const p = bawPreview({ from: me, to: pad.address, data: withNote(calldata.v2Sell(token, amount, minOut), note) })
|
|
365
|
+
return out(summarizeWithNote(p, {
|
|
284
366
|
step: 'sell', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
|
|
285
367
|
amount: fmtTok(amount), expectOut: q.outFmt, fee: q.feeFmt, minOut: fmt(minOut), unit: 'quote', quotePath: q.quotePath, deterministic: q.deterministic || false,
|
|
286
|
-
}))
|
|
368
|
+
}, note, 'sell'))
|
|
287
369
|
}
|
|
288
370
|
case 'upload-logo': {
|
|
289
371
|
const file = opt('file') || opt('path') || opt('logo')
|
|
@@ -294,22 +376,32 @@ async function main() {
|
|
|
294
376
|
case 'create': {
|
|
295
377
|
const name = opt('name'), symbol = opt('symbol'); if (!name || !symbol) fail('--name and --symbol required')
|
|
296
378
|
try { assertCreateTarget(CONFIG.launchpad) } catch (e) { fail(e.message) }
|
|
379
|
+
const note = parseNote()
|
|
380
|
+
// the old 5-arg --pair-or-quote never matched the contract
|
|
381
|
+
if (flag('pair-or-quote') || flag('pair')) {
|
|
382
|
+
fail('--pair-or-quote was removed (CLI ≤0.3.0 encoded a create the contract does not have). Use --quote <ERC-20> --ref-pool <Pancake pair/pool holding it>; omit both for WBNB.')
|
|
383
|
+
}
|
|
384
|
+
const taxed = flag('taxed')
|
|
385
|
+
const quote = createAddr('quote'), refPool = createAddr('ref-pool')
|
|
386
|
+
const wbnbQuote = [zeroAddress, ADDR.wbnb.toLowerCase()].includes(quote.toLowerCase())
|
|
297
387
|
const me = bawAddress()
|
|
298
388
|
if (!(await isAgent(me))) fail('this wallet holds no Agent ID (.ai); create is gated', { address: me })
|
|
389
|
+
// dry run before any logo upload; logoURI and the note suffix can't change whether create reverts
|
|
390
|
+
const dry = await preflightCreate(me, calldata.createV2(name, symbol, '', taxed, quote, refPool), quote, refPool)
|
|
391
|
+
if (!dry.ok && dry.reverted) fail(`create would revert: ${dry.error}`, { quote, refPool, hint: CREATE_HINTS[dry.error] })
|
|
392
|
+
if (!dry.ok) fail(`could not dry-run create (RPC): ${dry.error}`, { retry: 'same command again; set GANG_RPC_URL if this RPC keeps failing' })
|
|
299
393
|
const logo = await resolveLogoUri()
|
|
300
|
-
const
|
|
301
|
-
const pairOrQuote = opt('pair-or-quote') || opt('pair') || zeroAddress
|
|
302
|
-
if (taxed && (!pairOrQuote || pairOrQuote === zeroAddress)) {
|
|
303
|
-
fail('--taxed requires --pair-or-quote <pair or quote CA>')
|
|
304
|
-
}
|
|
305
|
-
const data = calldata.createV2(name, symbol, logo.logoURI, taxed, pairOrQuote)
|
|
394
|
+
const data = withNote(calldata.createV2(name, symbol, logo.logoURI, taxed, quote, refPool), note)
|
|
306
395
|
const p = bawPreview({ from: me, to: ADDR.v2, data })
|
|
307
|
-
return out(
|
|
396
|
+
return out(summarizeWithNote(p, {
|
|
308
397
|
step: 'create', pad: 'v2', launchpad: ADDR.v2, name, symbol,
|
|
309
398
|
logoURI: logo.logoURI || '(empty)', uploaded: logo.uploaded, upload: logo.upload,
|
|
310
|
-
taxed,
|
|
311
|
-
|
|
312
|
-
|
|
399
|
+
taxed, quote: wbnbQuote ? 'WBNB' : quote,
|
|
400
|
+
// the contract ignores refPool for WBNB (Created.refPair = 0x0)
|
|
401
|
+
refPool: wbnbQuote && refPool !== zeroAddress ? `${refPool} (ignored for WBNB)` : refPool,
|
|
402
|
+
graduationAmount: dry.gradAmt, tokenIfNext: dry.tokenIfNext,
|
|
403
|
+
note: 'LaunchpadV2 only. Graduation ≈15 BNB worth of the quote. No creation fee. Soft/U/S create archived. tokenIfNext changes if another create lands first: the real address is the Created event from `gang tx --hash …` after execute.',
|
|
404
|
+
}, note, 'launch'))
|
|
313
405
|
}
|
|
314
406
|
case 'execute': {
|
|
315
407
|
const id = opt('request-id'); if (!id) fail('--request-id required')
|
|
@@ -317,8 +409,18 @@ async function main() {
|
|
|
317
409
|
if (!r.success) return out({ success: false, error: r.error })
|
|
318
410
|
const d = r.data
|
|
319
411
|
const pending = d.status === 'PENDING_CONFIRMATION'
|
|
412
|
+
const entry = findByRequestId(id)
|
|
413
|
+
let note
|
|
414
|
+
if (entry && d.txHash && !pending) note = await revealNote(entry, d.txHash)
|
|
415
|
+
else if (entry && pending) {
|
|
416
|
+
note = { published: false, pending: true, retry: 'after the user approves in the Binance App: `gang tx --hash <hash>` publishes the note' }
|
|
417
|
+
} else if (entry) {
|
|
418
|
+
// any other status without a hash: nothing was broadcast, so there's nothing to reveal (and nothing to wait for)
|
|
419
|
+
note = { published: false, error: 'no_tx_hash', status: d.status }
|
|
420
|
+
}
|
|
320
421
|
return out({
|
|
321
422
|
success: true, status: d.status, txHash: d.txHash, orderId: d.orderId, pendingConfirmation: pending,
|
|
423
|
+
...(note && { note }),
|
|
322
424
|
next: pending
|
|
323
425
|
? 'Ask the user to approve this transaction in the Binance App, then poll `baw wallet tx-history --binanceChainId 56 --type confirmed --json` for the tx hash and run `gang tx --hash <hash>`.'
|
|
324
426
|
: (d.txHash ? `gang tx --hash ${d.txHash}` : undefined),
|
|
@@ -326,7 +428,26 @@ async function main() {
|
|
|
326
428
|
}
|
|
327
429
|
case 'tx': {
|
|
328
430
|
const hash = opt('hash'); if (!hash) fail('--hash required')
|
|
329
|
-
|
|
431
|
+
const t = await decodeTx(hash)
|
|
432
|
+
// a note committed in this tx (e.g. after PENDING_CONFIRMATION) is revealed here if it's still in the local queue;
|
|
433
|
+
// with nothing queued there's nothing to reveal, so skip the extra RPC read
|
|
434
|
+
let note
|
|
435
|
+
if (hasUnpublished()) {
|
|
436
|
+
try {
|
|
437
|
+
const committed = hashesIn((await client.getTransaction({ hash })).input)
|
|
438
|
+
if (committed.length) {
|
|
439
|
+
const entry = findByHashes(committed)
|
|
440
|
+
if (!entry) {
|
|
441
|
+
note = { committed: committed[0], published: false, error: 'not_in_local_queue', hint: 'note was written on another machine, or the queue entry is older than 24h' }
|
|
442
|
+
}
|
|
443
|
+
else if (t.status !== 'success') note = { published: false, error: 'tx_failed' }
|
|
444
|
+
else note = withRetry(await publishNote(entry, hash), hash)
|
|
445
|
+
}
|
|
446
|
+
} catch (e) {
|
|
447
|
+
note = { published: false, error: `could not read tx input: ${e.message.split('\n')[0]}` }
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return out({ success: true, ...t, ...(note && { note }) })
|
|
330
451
|
}
|
|
331
452
|
default:
|
|
332
453
|
console.log(HELP); process.exit(cmd ? 1 : 0)
|
package/src/notes.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent notes ("thoughts"): a short public reason attached to a buy / sell / create.
|
|
3
|
+
* baw can't sign messages, so the commitment rides at the end of the calldata (Solidity ignores trailing bytes):
|
|
4
|
+
* suffix = "gang" 0x01 keccak256(UTF-8("gang-note:v1\n" salt "\n" conf "\n" text)) (37 bytes)
|
|
5
|
+
* The text is revealed after the tx lands (POST /api/notes), so anyone can check it was written before the trade.
|
|
6
|
+
* Normalization and the commitment format must match the server byte for byte.
|
|
7
|
+
*/
|
|
8
|
+
import { randomBytes } from 'node:crypto'
|
|
9
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { keccak256, toHex } from 'viem'
|
|
13
|
+
import { API } from './discover.js'
|
|
14
|
+
|
|
15
|
+
export const MAGIC = '67616e6701' // "gang" + version 1
|
|
16
|
+
const MAX_CP = 280
|
|
17
|
+
const LINK = /https?:\/\/|www\.|t\.me\//i
|
|
18
|
+
// zero-width + bidi controls, and every C0/C1 control except the whitespace ones (\t \n \v \f \r become a space below)
|
|
19
|
+
const STRIP = /[\u200B-\u200D\uFEFF\u202A-\u202E\u2066-\u2069]|(?![\t\n\v\f\r])\p{Cc}/gu
|
|
20
|
+
const TTL_MS = 24 * 3600_000
|
|
21
|
+
const DIR = process.env.GANG_NOTES_DIR || join(homedir(), '.gang', 'notes')
|
|
22
|
+
const ENTRY = /^0x[0-9a-f]{64}\.json$/
|
|
23
|
+
const TMP = /^0x[0-9a-f]{64}\.json\.\d+\.tmp$/ // left behind only if a process died between write and rename
|
|
24
|
+
|
|
25
|
+
/** NFC → strip invisibles/controls → collapse whitespace → trim. Throws with the server's error code in the message. */
|
|
26
|
+
export function normalizeNote(raw) {
|
|
27
|
+
const text = String(raw ?? '').normalize('NFC').replace(STRIP, '').replace(/\s+/gu, ' ').trim()
|
|
28
|
+
const n = [...text].length
|
|
29
|
+
if (n < 1 || n > MAX_CP) throw new Error(`bad_text: note must be 1–${MAX_CP} characters after normalization (got ${n})`)
|
|
30
|
+
if (LINK.test(text)) throw new Error('link_not_allowed: note must not contain links (http://, www., t.me/)')
|
|
31
|
+
return text
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** conf is optional; null serializes as '' in the preimage. */
|
|
35
|
+
export function parseConf(raw) {
|
|
36
|
+
if (raw == null) return null
|
|
37
|
+
if (!/^\d{1,3}$/.test(String(raw)) || Number(raw) > 100) throw new Error(`bad_conf: --conf must be an integer 0–100 (got ${raw})`)
|
|
38
|
+
return Number(raw)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const noteHash = ({ salt, conf, text }) =>
|
|
42
|
+
keccak256(toHex(`gang-note:v1\n${salt}\n${conf ?? ''}\n${text}`))
|
|
43
|
+
|
|
44
|
+
export function makeNote(rawText, rawConf) {
|
|
45
|
+
const text = normalizeNote(rawText)
|
|
46
|
+
const conf = parseConf(rawConf)
|
|
47
|
+
const salt = randomBytes(16).toString('hex')
|
|
48
|
+
const hash = noteHash({ salt, conf, text })
|
|
49
|
+
return { text, conf, salt, hash, suffix: MAGIC + hash.slice(2) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const withNote = (data, note) => (note ? data + note.suffix : data)
|
|
53
|
+
|
|
54
|
+
/** Commitment hashes found in a tx input (contains-match, same as the server: the call may be wrapped). */
|
|
55
|
+
export function hashesIn(input) {
|
|
56
|
+
const s = String(input || '').toLowerCase()
|
|
57
|
+
return [...s.matchAll(/67616e6701([0-9a-f]{64})/g)].map((m) => `0x${m[1]}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---- local queue: one file per note, <dir>/<hash>.json → { salt, conf, text, kind, token, requestId, createdAt,
|
|
61
|
+
// publishedTx, publishedStatus }. Each process only ever writes its own note's file (atomic rename), so concurrent
|
|
62
|
+
// `gang` commands need no lock and a damaged file can't take the other notes' salts down with it.
|
|
63
|
+
|
|
64
|
+
const pathOf = (hash) => join(DIR, `${hash}.json`)
|
|
65
|
+
|
|
66
|
+
function read(hash) {
|
|
67
|
+
try { return JSON.parse(readFileSync(pathOf(hash), 'utf8')) } catch { return null }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function write(hash, entry) {
|
|
71
|
+
mkdirSync(DIR, { recursive: true, mode: 0o700 })
|
|
72
|
+
const tmp = `${pathOf(hash)}.${process.pid}.tmp`
|
|
73
|
+
writeFileSync(tmp, JSON.stringify(entry, null, 2), { mode: 0o600 })
|
|
74
|
+
renameSync(tmp, pathOf(hash))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const fresh = (e) => e?.createdAt > Date.now() - TTL_MS
|
|
78
|
+
|
|
79
|
+
const old = (path) => Date.now() - statSync(path).mtimeMs > TTL_MS
|
|
80
|
+
|
|
81
|
+
/** Live entries; entries, unreadable files and stray temp files past the 24h reveal window are deleted. */
|
|
82
|
+
function entries() {
|
|
83
|
+
let names
|
|
84
|
+
try { names = readdirSync(DIR) } catch { return [] }
|
|
85
|
+
const live = []
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
const path = join(DIR, name)
|
|
88
|
+
if (TMP.test(name)) {
|
|
89
|
+
try { if (old(path)) unlinkSync(path) } catch { /* already gone */ }
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (!ENTRY.test(name)) continue
|
|
93
|
+
const hash = name.slice(0, -5)
|
|
94
|
+
const e = read(hash)
|
|
95
|
+
if (fresh(e)) { live.push({ hash, ...e }); continue }
|
|
96
|
+
try {
|
|
97
|
+
// an unreadable file (e.g. damaged by hand) is kept for 24h so its salt can still be recovered manually
|
|
98
|
+
if (e || old(path)) unlinkSync(path)
|
|
99
|
+
} catch { /* already gone */ }
|
|
100
|
+
}
|
|
101
|
+
return live
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function queueNote(note, meta) {
|
|
105
|
+
write(note.hash, { salt: note.salt, conf: note.conf, text: note.text, ...meta, createdAt: Date.now(), publishedTx: null })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const hasUnpublished = () => entries().some((e) => !e.publishedTx)
|
|
109
|
+
|
|
110
|
+
export const findByRequestId = (requestId) => entries().find((e) => e.requestId === requestId) ?? null
|
|
111
|
+
|
|
112
|
+
export function findByHashes(hashes) {
|
|
113
|
+
for (const hash of hashes) {
|
|
114
|
+
const e = read(hash)
|
|
115
|
+
if (fresh(e)) return { hash, ...e }
|
|
116
|
+
}
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function markPublished(hash, tx, status) {
|
|
121
|
+
const e = read(hash)
|
|
122
|
+
if (e) write(hash, { ...e, publishedTx: tx, publishedStatus: status })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Reveal a queued note for a landed tx. Never throws: the trade already happened, so a failed reveal is
|
|
127
|
+
* reported (and stays queued for `gang tx --hash` to retry within 24h) instead of failing the command.
|
|
128
|
+
*/
|
|
129
|
+
export async function publishNote(entry, tx) {
|
|
130
|
+
if (entry.publishedTx === tx) return { published: true, status: entry.publishedStatus || 'ok', already: true }
|
|
131
|
+
const ctl = new AbortController()
|
|
132
|
+
const timer = setTimeout(() => ctl.abort(), 10_000)
|
|
133
|
+
try {
|
|
134
|
+
const r = await fetch(`${API}/notes`, {
|
|
135
|
+
method: 'POST', signal: ctl.signal,
|
|
136
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
137
|
+
body: JSON.stringify({ tx, salt: entry.salt, conf: entry.conf, text: entry.text }),
|
|
138
|
+
})
|
|
139
|
+
if (r.status === 200 || r.status === 202) {
|
|
140
|
+
let body
|
|
141
|
+
try { body = await r.json() } catch { /* status code is enough */ }
|
|
142
|
+
// ok | hidden (matched the blocklist, not shown) | pending (server waits for the indexer)
|
|
143
|
+
const status = body?.status || (r.status === 202 ? 'pending' : 'ok')
|
|
144
|
+
try { markPublished(entry.hash, tx, status) } catch { /* published anyway; a later `gang tx` just gets 200 again */ }
|
|
145
|
+
return { published: true, status }
|
|
146
|
+
}
|
|
147
|
+
let code
|
|
148
|
+
try { code = (await r.json())?.error } catch { /* non-JSON error body */ }
|
|
149
|
+
return { published: false, error: code || `http_${r.status}`, httpStatus: r.status }
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return { published: false, error: `unreachable: ${ctl.signal.aborted ? 'timed out after 10s' : e.message}` }
|
|
152
|
+
} finally {
|
|
153
|
+
clearTimeout(timer)
|
|
154
|
+
}
|
|
155
|
+
}
|