@gangfun/cli 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,8 +47,8 @@ gang token --token <addr>
47
47
  gang quote --token <addr> --bnb <n> # Soft archive: BNB; V2: quote ERC-20 (LaunchpadV2.sol math)
48
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 …] [--reveal-after …]]
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] [--quote 0x… --ref-pool 0x…] [--note … [--conf …] [--reveal-after …]]
50
+ gang upload-logo (--file ./avatar.png | --url <image URL>) # → https://gang.fun/api/logos/<id>.png
51
+ gang create --name <n> --symbol <s> [--logo ./avatar.png | --logo-uri URL] [--taxed] [--quote 0x… --ref-pool 0x…] [--note … [--conf …] [--reveal-after …]]
52
52
  gang execute --request-id <id>
53
53
  gang tx --hash <0x..>
54
54
  ```
@@ -58,17 +58,22 @@ gang tx --hash <0x..>
58
58
  Browser UI: https://gang.fun/dev/logo (pre-create tab).
59
59
 
60
60
  ```bash
61
- # Option A — upload then create
61
+ # Recommended — create uploads the file for you
62
+ gang create --name "My Agent Coin" --symbol MAC --logo ./avatar.png
63
+ gang execute --request-id …
64
+
65
+ # Or upload first, then create
62
66
  gang upload-logo --file ./avatar.png
63
67
  # → { "logoUrl": "https://gang.fun/api/logos/<id>.png", ... }
64
68
  gang create --name "My Agent Coin" --symbol MAC --logo-uri https://gang.fun/api/logos/<id>.png
65
69
  gang execute --request-id …
66
-
67
- # Option B — create uploads for you
68
- gang create --name "My Agent Coin" --symbol MAC --logo ./avatar.png
69
- gang execute --request-id …
70
70
  ```
71
71
 
72
+ `logoURI` is written on-chain and can't be changed, so the logo must stay online for good. `--logo-uri` with an image hosted
73
+ anywhere other than gang.fun is downloaded and copied to gang.fun first (png / jpeg / webp / gif, ≤512 KB), and the copy goes on-chain
74
+ (a gang.fun logo URL is only checked to exist);
75
+ temporary hosts such as litter.catbox.moe delete files within days. `--keep-logo-uri` puts a permanent URL (e.g. `ipfs://…`) on-chain
76
+ as is; temporary hosts are refused.
72
77
  `--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
78
  The CLI dry-runs `create` before preview and names the contract error (`RefPoolRequired`, `ThinReferencePool`, …) if it would revert.
74
79
  `--pair-or-quote` was removed: ≤0.3.0 encoded a 5-argument create that LaunchpadV2 doesn't have.
@@ -130,4 +135,4 @@ gang create --name "My Agent Coin" --symbol MAC --logo ./avatar.png --note "comm
130
135
 
131
136
  ## Version
132
137
 
133
- `0.4.1` · repo [gangfun/cli](https://github.com/gangfun/cli) · published to [npm](https://www.npmjs.com/package/@gangfun/cli) on push to `main`
138
+ `0.5.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
@@ -37,8 +37,8 @@ gang token --token <addr>
37
37
  gang quote --token <addr> --bnb <n>
38
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 …] [--reveal-after …]]
40
- gang upload-logo --file ./avatar.png
41
- gang create --name <n> --symbol <s> [--logo-uri URL | --logo ./avatar.png] [--taxed] [--quote 0x… --ref-pool 0x…] [--note … [--conf …] [--reveal-after …]]
40
+ gang upload-logo (--file ./avatar.png | --url <图片地址>)
41
+ gang create --name <n> --symbol <s> [--logo ./avatar.png | --logo-uri URL] [--taxed] [--quote 0x… --ref-pool 0x…] [--note … [--conf …] [--reveal-after …]]
42
42
  gang execute --request-id <id>
43
43
  gang tx --hash <0x..>
44
44
  ```
@@ -48,15 +48,20 @@ gang tx --hash <0x..>
48
48
  站内:https://gang.fun/dev/logo
49
49
 
50
50
  ```bash
51
- gang upload-logo --file ./avatar.png
52
- gang create --name "My Agent Coin" --symbol MAC --logo-uri https://gang.fun/api/logos/<id>.png
51
+ # 推荐:发币时直接上传本地图片
52
+ gang create --name "My Agent Coin" --symbol MAC --logo ./avatar.png
53
53
  gang execute --request-id …
54
54
 
55
- # 或一步上传
56
- gang create --name "My Agent Coin" --symbol MAC --logo ./avatar.png
55
+ # 或者先上传,再发币
56
+ gang upload-logo --file ./avatar.png
57
+ gang create --name "My Agent Coin" --symbol MAC --logo-uri https://gang.fun/api/logos/<id>.png
57
58
  gang execute --request-id …
58
59
  ```
59
60
 
61
+ `logoURI` 写在链上,发币后改不了,所以 logo 必须长期能打开。`--logo-uri` 填的不是 gang.fun 的图片时,CLI 会先下载这张图,
62
+ 转存到 gang.fun(png / jpeg / webp / gif,≤512 KB),上链的是转存后的地址(gang.fun 的地址只检查图片存在);litter.catbox.moe 这类临时图床几天内就会删图。
63
+ 确定地址是永久的(比如 `ipfs://…`),可以加 `--keep-logo-uri` 原样上链;临时图床会被拒绝。
64
+
60
65
  默认计价是 WBNB;`--taxed` 不需要池子。用其他代币计价时,加 `--quote <ERC-20> --ref-pool <持有该代币的 Pancake V2 pair / V3 池>`。
61
66
  preview 之前 CLI 会先模拟一次 `create`,如果会回滚,就直接报出合约的错误名(`RefPoolRequired`、`ThinReferencePool` 等)。
62
67
  `--pair-or-quote` 已移除:0.3.0 及之前的版本编码的是 LaunchpadV2 没有的 5 参数 create。
@@ -108,4 +113,4 @@ gang create --name "My Agent Coin" --symbol MAC --logo ./avatar.png --note "给
108
113
 
109
114
  ## 版本
110
115
 
111
- `0.4.1` · [gangfun/cli](https://github.com/gangfun/cli) · push 到 `main` 自动发布到 [npm](https://www.npmjs.com/package/@gangfun/cli)
116
+ `0.5.0` · [gangfun/cli](https://github.com/gangfun/cli) · push 到 `main` 自动发布到 [npm](https://www.npmjs.com/package/@gangfun/cli)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gangfun/cli",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Gang launchpad CLI (LaunchpadV2-only create + legacy Soft/U/S trade) for Binance Agentic Wallet agents.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -4,7 +4,7 @@ import {
4
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
- import { uploadLogoFile, LOGO_UI } from './logo.js'
7
+ import { uploadLogoFile, rehostLogoUrl, isGangLogo, checkGangLogo, tempHost, LOGO_UI } from './logo.js'
8
8
  import { API, fetchMarket, rank, watch, parseDuration } from './discover.js'
9
9
  import { makeNote, withNote, queueNote, findByRequestId, findByHashes, hashesIn, publishNote, hasQueued } from './notes.js'
10
10
 
@@ -73,9 +73,12 @@ const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet
73
73
  gang buy --token <addr> --bnb <n> [--slippage 1] [--note "<why>" [--conf 0-100] [--reveal-after 1-72h]]
74
74
  Soft: payable BNB; V2: wrap BNB→WBNB if short, approve quote, then buy
75
75
  gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1] [--note … [--conf …] [--reveal-after …]]
76
- gang upload-logo --file <path> POST image → https://gang.fun/api/logos → logoUrl
77
- gang create --name <n> --symbol <s> [--logo-uri URL | --logo <file>] [--taxed] [--quote 0x… --ref-pool 0x…] [--note … [--conf …] [--reveal-after …]]
76
+ gang upload-logo (--file <path> | --url <image URL>)
77
+ copy an image to https://gang.fun/api/logos → logoUrl
78
+ gang create --name <n> --symbol <s> [--logo <file> | --logo-uri URL] [--taxed] [--quote 0x… --ref-pool 0x…] [--note … [--conf …] [--reveal-after …]]
78
79
  LaunchpadV2 ONLY (${ADDR.v2}); Soft/U/S create refused
80
+ --logo-uri images are copied to gang.fun first (logoURI is permanent on-chain;
81
+ other hosts expire). --keep-logo-uri puts a permanent URL (e.g. ipfs://) on-chain as is
79
82
  gang execute --request-id <id> broadcast a previewed call
80
83
  gang tx --hash <0x..> wait for receipt and decode Gang events (publishes a queued --note)
81
84
 
@@ -164,13 +167,38 @@ const CREATE_HINTS = {
164
167
  GradAmtZero: 'the quote token is priced so high that the graduation amount rounds below the minimum',
165
168
  }
166
169
 
170
+ // logo.js marks network / 5xx failures retryable; anything else needs a different image or URL
171
+ const retryHint = (e) => (e.retryable ? { retry: 'same command again' } : {})
172
+
167
173
  async function resolveLogoUri() {
168
174
  const uri = opt('logo-uri')
169
175
  const file = opt('logo') || opt('logo-file') || opt('file-logo')
176
+ // `--logo-uri ""` is the documented empty logo; a missing value (or the next flag) is a mistake
177
+ if (flag('logo-uri') && (uri == null || uri.startsWith('--'))) fail('--logo-uri needs a URL; use --logo ./file.png for a local image')
170
178
  if (uri && file) fail('use either --logo-uri or --logo/--logo-file, not both')
171
- if (uri) return { logoURI: uri, uploaded: false }
179
+ if (flag('keep-logo-uri') && !uri) fail('--keep-logo-uri only applies together with --logo-uri <URL>')
180
+ if (uri) {
181
+ if (isGangLogo(uri)) {
182
+ try { await checkGangLogo(uri) } catch (e) { fail(e.message, retryHint(e)) }
183
+ return { logoURI: uri, uploaded: false }
184
+ }
185
+ const temp = tempHost(uri)
186
+ // logoURI can't be changed after create, so an image on another host is copied to gang.fun first
187
+ if (flag('keep-logo-uri')) {
188
+ if (temp) fail(`${temp} deletes uploads after a while, so this logo would break and can't be fixed on-chain; drop --keep-logo-uri (the image is then copied to gang.fun) or use --logo ./file.png`)
189
+ return { logoURI: uri, uploaded: false }
190
+ }
191
+ try {
192
+ const up = await rehostLogoUrl(uri)
193
+ return { logoURI: up.logoUrl, uploaded: true, upload: up }
194
+ } catch (e) {
195
+ fail(e.message, e.retryable ? { retry: 'same command again' } : { hint: temp
196
+ ? `${temp} deletes uploads after a while; save the image and use --logo ./file.png`
197
+ : 'use --logo ./file.png, or --keep-logo-uri if this URL is permanent (e.g. ipfs://) and should go on-chain as is' })
198
+ }
199
+ }
172
200
  if (file) {
173
- const up = await uploadLogoFile(file)
201
+ const up = await uploadLogoFile(file).catch((e) => fail(e.message, retryHint(e)))
174
202
  return { logoURI: up.logoUrl, uploaded: true, upload: up }
175
203
  }
176
204
  return { logoURI: '', uploaded: false }
@@ -402,8 +430,10 @@ async function main() {
402
430
  }
403
431
  case 'upload-logo': {
404
432
  const file = opt('file') || opt('path') || opt('logo')
405
- if (!file) fail('--file <path> required (png/jpeg/webp/gif ≤512KB)')
406
- const up = await uploadLogoFile(file)
433
+ const url = opt('url')
434
+ if (file && url) fail('use either --file or --url, not both')
435
+ if (!file && !url) fail('--file <path> or --url <image URL> required (png/jpeg/webp/gif ≤512KB)')
436
+ const up = await (file ? uploadLogoFile(file) : rehostLogoUrl(url)).catch((e) => fail(e.message, retryHint(e)))
407
437
  return out({ success: true, ...up, ui: LOGO_UI, next: `gang create --name … --symbol … --logo-uri ${up.logoUrl}` })
408
438
  }
409
439
  case 'create': {
package/src/logo.js CHANGED
@@ -1,40 +1,118 @@
1
1
  import { readFileSync, statSync } from 'node:fs'
2
- import { extname, resolve } from 'node:path'
2
+ import { resolve } from 'node:path'
3
3
 
4
4
  const MAX_BYTES = 512 * 1024
5
- const MIME = {
6
- '.png': 'image/png',
7
- '.jpg': 'image/jpeg',
8
- '.jpeg': 'image/jpeg',
9
- '.webp': 'image/webp',
10
- '.gif': 'image/gif',
11
- }
5
+ const FETCH_TIMEOUT_MS = 20_000
12
6
 
13
- export const LOGO_API = process.env.GANG_LOGO_API || 'https://gang.fun/api/logos'
7
+ export const LOGO_API = (process.env.GANG_LOGO_API || 'https://gang.fun/api/logos').replace(/\/+$/, '')
14
8
  export const LOGO_UI = 'https://gang.fun/dev/logo'
15
9
 
16
- /** Read a local image → data URL → POST /api/logos → { logoUrl, id, bytes }. */
10
+ // file hosts that delete uploads after hours or days: a logoURI there turns into a broken image, and it can't be changed on-chain
11
+ const TEMP_HOSTS = ['litter.catbox.moe', 'tmpfiles.org', '0x0.st', 'uguu.se', 'file.io', 'transfer.sh']
12
+
13
+ // network failures, timeouts and 5xx / 429 may pass on a retry; a missing or wrong image won't
14
+ // fetch wraps every failure (also ipfs://, bad URL, expired TLS) in a cause; only these codes and timeouts are transient
15
+ const NET_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'EPIPE', 'ENETUNREACH', 'EHOSTUNREACH',
16
+ 'UND_ERR_SOCKET', 'UND_ERR_CLOSED', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT']
17
+ const fetchError = (msg, e) => Object.assign(new Error(msg), {
18
+ retryable: ['TimeoutError', 'AbortError'].includes(e.name) || NET_CODES.includes(e.cause?.code),
19
+ })
20
+ const statusError = (msg, status) => Object.assign(new Error(msg), { retryable: status >= 500 || status === 429 })
21
+ const why = (e) => e.cause?.message || e.message
22
+
23
+ /** The image type from its first bytes; only the types gang.fun makes thumbnails for (png / jpeg / gif / webp). */
24
+ function sniffMime(buf) {
25
+ if (buf.length >= 8 && buf.readUInt32BE(0) === 0x89504e47 && buf.readUInt32BE(4) === 0x0d0a1a0a) return 'image/png'
26
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'
27
+ if (buf.length >= 6 && /^GIF8[79]a$/.test(buf.toString('latin1', 0, 6))) return 'image/gif'
28
+ if (buf.length >= 12 && buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') return 'image/webp'
29
+ return null
30
+ }
31
+
32
+ /** Image bytes → data URL → POST /api/logos → { logoUrl, id, bytes }. */
33
+ async function postLogo(buf, what) {
34
+ if (buf.length <= 0) throw new Error(`empty image: ${what}`)
35
+ if (buf.length > MAX_BYTES) throw new Error(`logo too large: ${buf.length} bytes (max ${MAX_BYTES}): ${what}`)
36
+ const mime = sniffMime(buf)
37
+ if (!mime) throw new Error(`not a png/jpeg/webp/gif image: ${what}`)
38
+ const image = `data:${mime};base64,${buf.toString('base64')}`
39
+ let res, text
40
+ try {
41
+ res = await fetch(LOGO_API, {
42
+ method: 'POST',
43
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
44
+ body: JSON.stringify({ image }),
45
+ })
46
+ text = await res.text()
47
+ } catch (e) {
48
+ throw fetchError(`logo upload failed: ${why(e)}`, e)
49
+ }
50
+ let json
51
+ try { json = JSON.parse(text) } catch { throw statusError(`logo upload non-JSON (${res.status}): ${text.slice(0, 200)}`, res.status) }
52
+ if (!res.ok || !json?.ok || !json?.logoUrl) {
53
+ throw statusError(`logo upload failed (${res.status}): ${json?.error || text.slice(0, 200)}`, res.status)
54
+ }
55
+ return { logoUrl: json.logoUrl, id: json.id, bytes: json.bytes ?? buf.length, api: LOGO_API }
56
+ }
57
+
58
+ /** Read a local image → POST /api/logos → { logoUrl, id, bytes }. */
17
59
  export async function uploadLogoFile(filePath) {
18
60
  const abs = resolve(filePath)
19
61
  const st = statSync(abs)
20
62
  if (!st.isFile()) throw new Error(`not a file: ${abs}`)
21
- if (st.size <= 0) throw new Error(`empty file: ${abs}`)
22
63
  if (st.size > MAX_BYTES) throw new Error(`logo too large: ${st.size} bytes (max ${MAX_BYTES})`)
23
- const ext = extname(abs).toLowerCase()
24
- const mime = MIME[ext]
25
- if (!mime) throw new Error(`unsupported image type ${ext || '(none)'}; use png/jpeg/webp/gif`)
26
- const b64 = readFileSync(abs).toString('base64')
27
- const image = `data:${mime};base64,${b64}`
28
- const res = await fetch(LOGO_API, {
29
- method: 'POST',
30
- headers: { 'content-type': 'application/json', accept: 'application/json' },
31
- body: JSON.stringify({ image }),
32
- })
33
- const text = await res.text()
34
- let json
35
- try { json = JSON.parse(text) } catch { throw new Error(`logo upload non-JSON (${res.status}): ${text.slice(0, 200)}`) }
36
- if (!res.ok || !json?.ok || !json?.logoUrl) {
37
- throw new Error(json?.error || `logo upload failed (${res.status}): ${text.slice(0, 200)}`)
64
+ return postLogo(readFileSync(abs), abs)
65
+ }
66
+
67
+ /** True when the URL is already a gang.fun logo (nothing to copy). */
68
+ export const isGangLogo = (uri) => uri.startsWith(`${LOGO_API}/`)
69
+
70
+ /** Throw unless a gang.fun logo URL serves an image (a typo'd id would go on-chain as a broken logo). Reads headers only. */
71
+ export async function checkGangLogo(uri) {
72
+ let res
73
+ try {
74
+ // the logo route answers HEAD with 405, so GET and drop the body
75
+ res = await fetch(uri, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
76
+ await res.body?.cancel()
77
+ } catch (e) {
78
+ throw fetchError(`could not check logo ${uri}: ${why(e)}`, e)
79
+ }
80
+ if (!res.ok || !res.headers.get('content-type')?.startsWith('image/')) {
81
+ throw statusError(`no logo at ${uri} (HTTP ${res.status}); upload it with \`gang upload-logo --file …\` and use the logoUrl it returns`, res.status)
82
+ }
83
+ }
84
+
85
+ /** The temporary file host a URL points at, or null. */
86
+ export function tempHost(uri) {
87
+ let host
88
+ try { host = new URL(uri).hostname.toLowerCase() } catch { return null }
89
+ return TEMP_HOSTS.find((h) => host === h || host.endsWith(`.${h}`)) || null
90
+ }
91
+
92
+ /** Download an image URL (≤512 KB) → POST /api/logos → { logoUrl, id, bytes, source }. */
93
+ export async function rehostLogoUrl(uri) {
94
+ const msg = `could not download logo ${uri}`
95
+ let res
96
+ try {
97
+ res = await fetch(uri, { redirect: 'follow', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { accept: 'image/*' } })
98
+ } catch (e) {
99
+ throw fetchError(`${msg}: ${why(e)}`, e)
100
+ }
101
+ if (!res.ok) throw statusError(`${msg}: HTTP ${res.status}`, res.status)
102
+ const len = Number(res.headers.get('content-length'))
103
+ if (len > MAX_BYTES) throw new Error(`${msg}: logo too large: ${len} bytes (max ${MAX_BYTES})`)
104
+ // read with a cap: content-length can be missing or wrong
105
+ const chunks = []
106
+ let size = 0
107
+ try {
108
+ for await (const c of res.body) {
109
+ size += c.length
110
+ if (size > MAX_BYTES) break
111
+ chunks.push(c)
112
+ }
113
+ } catch (e) {
114
+ throw fetchError(`${msg}: ${why(e)}`, e)
38
115
  }
39
- return { logoUrl: json.logoUrl, id: json.id, bytes: json.bytes ?? st.size, api: LOGO_API }
116
+ if (size > MAX_BYTES) throw new Error(`${msg}: logo too large: over ${MAX_BYTES} bytes`)
117
+ return { ...(await postLogo(Buffer.concat(chunks), uri)), source: uri }
40
118
  }