@gangfun/cli 0.3.1 → 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 +6 -3
- package/README.zh-CN.md +6 -3
- package/package.json +1 -1
- package/src/abi.js +5 -0
- package/src/chain.js +2 -1
- package/src/index.js +47 -14
- package/src/notes.js +82 -23
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ 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] [--note "<why>" [--conf 0-100]]
|
|
48
|
+
gang buy --token <addr> --bnb <n> [--slippage 1] [--note "<why>" [--conf 0-100] [--reveal-after 1-72h]]
|
|
49
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
51
|
gang create --name <n> --symbol <s> [--logo-uri URL | --logo ./avatar.png] [--taxed] [--quote 0x… --ref-pool 0x…] [--note …]
|
|
@@ -94,10 +94,13 @@ gang watch --events new_token,near_grad --max-events 1 --timeout 30m
|
|
|
94
94
|
`--note "<why>" [--conf 0-100]` on buy / sell / create attaches a **public** reason, shown on gang.fun next to the trade.
|
|
95
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
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.
|
|
97
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).
|
|
98
100
|
|
|
99
101
|
```bash
|
|
100
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
|
|
101
104
|
```
|
|
102
105
|
|
|
103
106
|
## Quote path
|
|
@@ -121,8 +124,8 @@ gang buy --token 0x… --bnb 0.1 --note "volume doubled in 1h, buying the breako
|
|
|
121
124
|
| `GANG_RPC_URL` | BSC HTTP RPC |
|
|
122
125
|
| `GANG_LOGO_API` | Logo upload endpoint (default `https://gang.fun/api/logos`) |
|
|
123
126
|
| `GANG_API` | Indexer API for discovery and note publishing (default `https://gang.fun/api`) |
|
|
124
|
-
| `GANG_NOTES_DIR` | Local queue
|
|
127
|
+
| `GANG_NOTES_DIR` | Local note queue, one file per note: salt and send status, kept 24h (default `~/.gang/notes`) |
|
|
125
128
|
|
|
126
129
|
## Version
|
|
127
130
|
|
|
128
|
-
`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,7 +35,7 @@ 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] [--note "<理由>" [--conf 0-100]]
|
|
38
|
+
gang buy --token <addr> --bnb <n> [--slippage 1] [--note "<理由>" [--conf 0-100] [--reveal-after 1-72h]]
|
|
39
39
|
gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1] [--note … [--conf …]]
|
|
40
40
|
gang upload-logo --file ./avatar.png
|
|
41
41
|
gang create --name <n> --symbol <s> [--logo-uri URL | --logo ./avatar.png] [--taxed] [--quote 0x… --ref-pool 0x…] [--note …]
|
|
@@ -74,10 +74,13 @@ preview 之前 CLI 会先模拟一次 `create`,如果会回滚,就直接报
|
|
|
74
74
|
在 buy / sell / create 后面加 `--note "<理由>" [--conf 0-100]`,就能给这笔交易附一句理由。附言**会公开**,显示在 gang.fun 上这笔交易旁边。
|
|
75
75
|
附言的哈希会追加到交易 calldata 末尾(37 字节,合约会忽略),因此可以证明它写在交易之前,事后也改不了。
|
|
76
76
|
附言限 1–280 字,不能含链接。`gang execute` 在拿到回执后自动公开;如果交易需要 App 确认,就在确认后用 `gang tx --hash` 补发。
|
|
77
|
+
加 `--reveal-after 6h` 就是密封想法(41 字节承诺):gang.fun 先保管正文,期间别人只看到"有一条密封想法",交易后 6 小时自动公开。
|
|
78
|
+
提交后不能撤回;交易后 24 小时内没提交的密封想法,会记为"未兑现"。
|
|
77
79
|
公开失败不影响交易本身。详见 [skill/references/notes.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/notes.md)。
|
|
78
80
|
|
|
79
81
|
```bash
|
|
80
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 小时
|
|
81
84
|
```
|
|
82
85
|
|
|
83
86
|
## Quote 路径
|
|
@@ -99,8 +102,8 @@ gang buy --token 0x… --bnb 0.1 --note "1 小时量能翻倍,买突破" --con
|
|
|
99
102
|
| `GANG_RPC_URL` | BSC RPC |
|
|
100
103
|
| `GANG_LOGO_API` | Logo 上传 API(默认 `https://gang.fun/api/logos`) |
|
|
101
104
|
| `GANG_API` | indexer API,发现命令和公开附言都用它(默认 `https://gang.fun/api`) |
|
|
102
|
-
| `GANG_NOTES_DIR` |
|
|
105
|
+
| `GANG_NOTES_DIR` | 附言的本地队列,每条一个文件,记着 salt 和提交状态,保留 24 小时(默认 `~/.gang/notes`) |
|
|
103
106
|
|
|
104
107
|
## 版本
|
|
105
108
|
|
|
106
|
-
`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
|
@@ -72,5 +72,10 @@ export const ERC20_ABI = [
|
|
|
72
72
|
{ type: 'function', name: 'locked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
|
|
73
73
|
]
|
|
74
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
|
+
|
|
75
80
|
/** @deprecated alias kept for older imports */
|
|
76
81
|
export const LAUNCHPAD_ABI = V2_ABI
|
package/src/chain.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
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',
|
|
@@ -254,6 +254,7 @@ 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
|
+
wrap: () => encodeFunctionData({ abi: WBNB_ABI, functionName: 'deposit' }),
|
|
257
258
|
createV2: (name, symbol, logoURI, taxed, quote, refPool) =>
|
|
258
259
|
encodeFunctionData({ abi: V2_ABI, functionName: 'create', args: [name, symbol, logoURI, taxed, quote, refPool] }),
|
|
259
260
|
}
|
package/src/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
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,
|
|
9
|
+
import { makeNote, withNote, queueNote, findByRequestId, findByHashes, hashesIn, publishNote, hasQueued } from './notes.js'
|
|
10
10
|
|
|
11
11
|
const args = process.argv.slice(2)
|
|
12
12
|
const cmd = args[0]
|
|
@@ -37,11 +37,14 @@ const parsePct = (raw) => {
|
|
|
37
37
|
return n
|
|
38
38
|
}
|
|
39
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')
|
|
40
42
|
|
|
41
43
|
// --note is validated before any RPC / preview, so a bad note never reaches calldata
|
|
42
44
|
const parseNote = () => {
|
|
43
45
|
if (!flag('note')) {
|
|
44
46
|
if (flag('conf')) fail('--conf only applies together with --note')
|
|
47
|
+
if (flag('reveal-after')) fail('--reveal-after only applies together with --note')
|
|
45
48
|
return null
|
|
46
49
|
}
|
|
47
50
|
const raw = opt('note')
|
|
@@ -50,7 +53,8 @@ const parseNote = () => {
|
|
|
50
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"')
|
|
51
54
|
}
|
|
52
55
|
if (flag('conf') && opt('conf') == null) fail('--conf needs a value 0–100')
|
|
53
|
-
|
|
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) }
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet) · LaunchpadV2 create
|
|
@@ -66,17 +70,19 @@ const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet
|
|
|
66
70
|
no addr: new_token / near_grad / graduated across the market
|
|
67
71
|
gang token --token <addr> one token (auto-resolves pad)
|
|
68
72
|
gang quote --token <addr> --bnb <n> Soft: BNB quote; V2/U/S: quote-token amount (same flag)
|
|
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 …]]
|
|
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 …]]
|
|
72
76
|
gang upload-logo --file <path> POST image → https://gang.fun/api/logos → logoUrl
|
|
73
|
-
gang create --name <n> --symbol <s> [--logo-uri URL | --logo <file>] [--taxed] [--quote 0x… --ref-pool 0x…] [--note … [--conf …]]
|
|
77
|
+
gang create --name <n> --symbol <s> [--logo-uri URL | --logo <file>] [--taxed] [--quote 0x… --ref-pool 0x…] [--note … [--conf …] [--reveal-after …]]
|
|
74
78
|
LaunchpadV2 ONLY (${ADDR.v2}); Soft/U/S create refused
|
|
75
79
|
gang execute --request-id <id> broadcast a previewed call
|
|
76
80
|
gang tx --hash <0x..> wait for receipt and decode Gang events (publishes a queued --note)
|
|
77
81
|
|
|
78
82
|
--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.
|
|
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.
|
|
80
86
|
|
|
81
87
|
Logo UI (browser): ${LOGO_UI}
|
|
82
88
|
env: GANG_LAUNCHPAD (default V2 ${ADDR.v2}), GANG_RPC_URL, GANG_LOGO_API, GANG_API (default ${API}), GANG_NOTES_DIR (default ~/.gang/notes)`
|
|
@@ -100,12 +106,17 @@ function summarizeWithNote(p, extra, note, kind) {
|
|
|
100
106
|
const s = summarizePreview(p, extra)
|
|
101
107
|
if (note && s.success) {
|
|
102
108
|
queueNote(note, { kind, token: extra.token ?? null, requestId: s.requestId })
|
|
103
|
-
s.agentNote = {
|
|
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
|
+
}
|
|
104
115
|
}
|
|
105
116
|
return s
|
|
106
117
|
}
|
|
107
118
|
const noteSkipped = (note) =>
|
|
108
|
-
note ? { agentNote: { attached: false, reason: '
|
|
119
|
+
note ? { agentNote: { attached: false, reason: 'this setup step carries no note; pass --note again on the re-run' } } : {}
|
|
109
120
|
|
|
110
121
|
/** Reveal after the tx is mined (the server checks receipt status). Failures don't affect the trade. */
|
|
111
122
|
async function revealNote(entry, hash) {
|
|
@@ -310,9 +321,31 @@ async function main() {
|
|
|
310
321
|
}, note, 'buy'))
|
|
311
322
|
}
|
|
312
323
|
|
|
313
|
-
// V2 / U / S: quote ERC-20 approve
|
|
324
|
+
// V2 / U / S: buy(token, quoteIn, minTokensOut) pulls quote ERC-20, so: enough quote → approve → buy
|
|
314
325
|
const quoteIn = parseEther(bnb)
|
|
315
|
-
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
|
+
}
|
|
316
349
|
if (allow < quoteIn) {
|
|
317
350
|
const p = bawPreview({ from: me, to: info.quote, data: calldata.approve(pad.address, quoteIn) })
|
|
318
351
|
return out(summarizePreview(p, {
|
|
@@ -429,10 +462,10 @@ async function main() {
|
|
|
429
462
|
case 'tx': {
|
|
430
463
|
const hash = opt('hash'); if (!hash) fail('--hash required')
|
|
431
464
|
const t = await decodeTx(hash)
|
|
432
|
-
// a note committed in this tx
|
|
433
|
-
//
|
|
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
|
|
434
467
|
let note
|
|
435
|
-
if (
|
|
468
|
+
if (hasQueued()) {
|
|
436
469
|
try {
|
|
437
470
|
const committed = hashesIn((await client.getTransaction({ hash })).input)
|
|
438
471
|
if (committed.length) {
|
package/src/notes.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent notes ("thoughts"): a short public reason attached to a buy / sell / create.
|
|
3
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))
|
|
5
|
-
*
|
|
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.
|
|
6
8
|
* Normalization and the commitment format must match the server byte for byte.
|
|
7
9
|
*/
|
|
8
10
|
import { randomBytes } from 'node:crypto'
|
|
@@ -13,11 +15,13 @@ import { keccak256, toHex } from 'viem'
|
|
|
13
15
|
import { API } from './discover.js'
|
|
14
16
|
|
|
15
17
|
export const MAGIC = '67616e6701' // "gang" + version 1
|
|
18
|
+
export const MAGIC_V2 = '67616e6702' // "gang" + version 2 (sealed)
|
|
16
19
|
const MAX_CP = 280
|
|
20
|
+
const REVEAL_MIN_H = 1, REVEAL_MAX_H = 72
|
|
17
21
|
const LINK = /https?:\/\/|www\.|t\.me\//i
|
|
18
22
|
// zero-width + bidi controls, and every C0/C1 control except the whitespace ones (\t \n \v \f \r become a space below)
|
|
19
23
|
const STRIP = /[\u200B-\u200D\uFEFF\u202A-\u202E\u2066-\u2069]|(?![\t\n\v\f\r])\p{Cc}/gu
|
|
20
|
-
const TTL_MS = 24 * 3600_000
|
|
24
|
+
const TTL_MS = 24 * 3600_000 // the server takes a note (v1 or v2) for 24h after the trade
|
|
21
25
|
const DIR = process.env.GANG_NOTES_DIR || join(homedir(), '.gang', 'notes')
|
|
22
26
|
const ENTRY = /^0x[0-9a-f]{64}\.json$/
|
|
23
27
|
const TMP = /^0x[0-9a-f]{64}\.json\.\d+\.tmp$/ // left behind only if a process died between write and rename
|
|
@@ -38,27 +42,45 @@ export function parseConf(raw) {
|
|
|
38
42
|
return Number(raw)
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
|
|
42
|
-
|
|
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}`))
|
|
43
60
|
|
|
44
|
-
export function makeNote(rawText, rawConf) {
|
|
61
|
+
export function makeNote(rawText, rawConf, rawRevealAfter) {
|
|
45
62
|
const text = normalizeNote(rawText)
|
|
46
63
|
const conf = parseConf(rawConf)
|
|
64
|
+
const revealAfter = parseRevealAfter(rawRevealAfter)
|
|
47
65
|
const salt = randomBytes(16).toString('hex')
|
|
48
|
-
const hash = noteHash({ salt, conf, text })
|
|
49
|
-
|
|
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 }
|
|
50
71
|
}
|
|
51
72
|
|
|
52
73
|
export const withNote = (data, note) => (note ? data + note.suffix : data)
|
|
53
74
|
|
|
54
|
-
/** Commitment hashes found in a tx input (contains-match, same as the server: the call may be wrapped). */
|
|
75
|
+
/** Commitment hashes (v1 and v2) found in a tx input (contains-match, same as the server: the call may be wrapped). */
|
|
55
76
|
export function hashesIn(input) {
|
|
56
77
|
const s = String(input || '').toLowerCase()
|
|
57
|
-
return [...s.matchAll(/
|
|
78
|
+
return [...s.matchAll(/67616e67(?:01|02[0-9a-f]{8})([0-9a-f]{64})/g)].map((m) => `0x${m[1]}`)
|
|
58
79
|
}
|
|
59
80
|
|
|
60
|
-
// ---- local queue: one file per note, <dir>/<hash>.json → { salt, conf, text, kind, token, requestId,
|
|
61
|
-
// publishedTx, publishedStatus }.
|
|
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
|
|
62
84
|
// `gang` commands need no lock and a damaged file can't take the other notes' salts down with it.
|
|
63
85
|
|
|
64
86
|
const pathOf = (hash) => join(DIR, `${hash}.json`)
|
|
@@ -102,10 +124,13 @@ function entries() {
|
|
|
102
124
|
}
|
|
103
125
|
|
|
104
126
|
export function queueNote(note, meta) {
|
|
105
|
-
write(note.hash, {
|
|
127
|
+
write(note.hash, {
|
|
128
|
+
salt: note.salt, conf: note.conf, text: note.text, revealAfter: note.revealAfter, ...meta, createdAt: Date.now(), publishedTx: null,
|
|
129
|
+
})
|
|
106
130
|
}
|
|
107
131
|
|
|
108
|
-
|
|
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
|
|
109
134
|
|
|
110
135
|
export const findByRequestId = (requestId) => entries().find((e) => e.requestId === requestId) ?? null
|
|
111
136
|
|
|
@@ -117,32 +142,66 @@ export function findByHashes(hashes) {
|
|
|
117
142
|
return null
|
|
118
143
|
}
|
|
119
144
|
|
|
120
|
-
function markPublished(hash, tx, status) {
|
|
145
|
+
function markPublished(hash, tx, status, revealAt) {
|
|
121
146
|
const e = read(hash)
|
|
122
|
-
if (e) write(hash, { ...e, publishedTx: tx, publishedStatus: status })
|
|
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
|
|
123
156
|
}
|
|
124
157
|
|
|
125
158
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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.
|
|
128
175
|
*/
|
|
129
176
|
export async function publishNote(entry, tx) {
|
|
130
|
-
if (entry.publishedTx
|
|
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) {
|
|
131
187
|
const ctl = new AbortController()
|
|
132
188
|
const timer = setTimeout(() => ctl.abort(), 10_000)
|
|
133
189
|
try {
|
|
134
190
|
const r = await fetch(`${API}/notes`, {
|
|
135
191
|
method: 'POST', signal: ctl.signal,
|
|
136
192
|
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
137
|
-
body: JSON.stringify({
|
|
193
|
+
body: JSON.stringify({
|
|
194
|
+
tx, salt: entry.salt, conf: entry.conf, text: entry.text, ...(entry.revealAfter && { revealAfter: entry.revealAfter }),
|
|
195
|
+
}),
|
|
138
196
|
})
|
|
139
197
|
if (r.status === 200 || r.status === 202) {
|
|
140
198
|
let body
|
|
141
199
|
try { body = await r.json() } catch { /* status code is enough */ }
|
|
142
|
-
// ok | hidden (matched the blocklist, not shown) | pending (server waits for the indexer)
|
|
200
|
+
// ok | hidden (matched the blocklist, not shown) | pending (server waits for the indexer) | sealed (v2, shown from revealAt)
|
|
143
201
|
const status = body?.status || (r.status === 202 ? 'pending' : 'ok')
|
|
144
|
-
|
|
145
|
-
|
|
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)
|
|
146
205
|
}
|
|
147
206
|
let code
|
|
148
207
|
try { code = (await r.json())?.error } catch { /* non-JSON error body */ }
|