@gangfun/cli 0.1.4 → 0.3.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/src/index.js CHANGED
@@ -1,13 +1,23 @@
1
1
  #!/usr/bin/env node
2
- import { CONFIG, listTokens, tokenInfo, quoteBuy, quoteSell, isAgent, balanceOf, allowance, calldata, decodeTx, parseEther, fmt, fmtTok } from './chain.js'
2
+ import {
3
+ CONFIG, ADDR, listTokens, tokenInfo, quoteBuy, quoteSell, isAgent, balanceOf, allowance,
4
+ calldata, decodeTx, parseEther, fmt, fmtTok, resolvePad, assertCreateTarget, zeroAddress,
5
+ } from './chain.js'
3
6
  import { bawAddress, bawSettings, bawStatus, bawPreview, bawExecute } from './baw.js'
7
+ import { uploadLogoFile, LOGO_UI } from './logo.js'
8
+ import { API, fetchMarket, rank, watch, parseDuration } from './discover.js'
4
9
 
5
10
  const args = process.argv.slice(2)
6
11
  const cmd = args[0]
7
12
  const opt = (name, def) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : def }
8
13
  const flag = (name) => args.includes(`--${name}`)
9
14
  const out = (o) => console.log(JSON.stringify(o, (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2))
10
- const fail = (msg, extra = {}) => { out({ success: false, error: msg, ...extra }); process.exit(1) }
15
+ // `gang watch` speaks NDJSON, so its failures are one line too (with event/fatal for stream consumers)
16
+ const fail = (msg, extra = {}) => {
17
+ if (cmd === 'watch') console.log(JSON.stringify({ event: 'error', fatal: true, success: false, error: msg, ...extra }))
18
+ else out({ success: false, error: msg, ...extra })
19
+ process.exit(1)
20
+ }
11
21
 
12
22
  const parseSlippage = (raw) => {
13
23
  const s = Number(raw)
@@ -15,19 +25,41 @@ const parseSlippage = (raw) => {
15
25
  return s
16
26
  }
17
27
 
18
- const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet)
28
+ const parseCount = (name, raw, max) => {
29
+ const n = Number(raw)
30
+ if (!Number.isInteger(n) || n < 1 || n > max) fail(`--${name} must be an integer between 1 and ${max}`, { got: raw })
31
+ return n
32
+ }
33
+ const parsePct = (raw) => {
34
+ const n = Number(raw)
35
+ if (!Number.isFinite(n) || n < 0 || n > 100) fail('--min-pct must be a number between 0 and 100', { got: raw })
36
+ return n
37
+ }
38
+ const WATCH_EVENTS = ['trade', 'progress', 'graduated', 'new_token', 'near_grad']
39
+
40
+ const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet) · LaunchpadV2 create
19
41
 
20
42
  gang preflight check sign-in, Developer Mode, tradeAllTokens, Agent ID
21
- gang list all tokens with price, progress, my balance
22
- gang token --token <addr> one token in detail
23
- gang quote --token <addr> --bnb <n> buy quote for my wallet (fee, cap, refund)
24
- gang buy --token <addr> --bnb <n> [--slippage 1] preview a buy -> requestId
25
- gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1] preview approve or sell -> requestId
26
- gang create --name <n> --symbol <s> preview token creation -> requestId
43
+ gang list tokens across V2 / Soft / U / S
44
+ gang hot [--limit 10] [--track all|v2|soft] [--include-graduated]
45
+ curve tokens ranked by 24h USD volume
46
+ gang new [--limit 10] [--max-age 2h] [--track …] newest curve tokens first
47
+ gang near-grad [--min-pct 50] [--limit 10] [--track …] closest to graduation
48
+ gang watch [<addr>] [--interval 10] [--min-pct 50] [--events a,b] [--max-events N] [--timeout 30m] [--webhook URL]
49
+ NDJSON stream (min interval: 3s token, 10s market). <addr>: trade / progress / graduated.
50
+ no addr: new_token / near_grad / graduated across the market
51
+ gang token --token <addr> one token (auto-resolves pad)
52
+ gang quote --token <addr> --bnb <n> Soft: BNB quote; V2/U/S: quote-token amount (same flag)
53
+ gang buy --token <addr> --bnb <n> [--slippage 1] Soft: payable BNB; V2: approve quote then buy
54
+ gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1]
55
+ gang upload-logo --file <path> POST image → https://gang.fun/api/logos → logoUrl
56
+ gang create --name <n> --symbol <s> [--logo-uri URL | --logo <file>] [--taxed] [--pair-or-quote 0x…]
57
+ LaunchpadV2 ONLY (${ADDR.v2}); Soft/U/S create refused
27
58
  gang execute --request-id <id> broadcast a previewed call
28
59
  gang tx --hash <0x..> wait for receipt and decode Gang events
29
60
 
30
- env: GANG_LAUNCHPAD (default ${CONFIG.launchpad}), GANG_RPC_URL`
61
+ Logo UI (browser): ${LOGO_UI}
62
+ env: GANG_LAUNCHPAD (default V2 ${ADDR.v2}), GANG_RPC_URL, GANG_LOGO_API, GANG_API (default ${API})`
31
63
 
32
64
  function summarizePreview(p, extra = {}) {
33
65
  if (!p.success) return { success: false, step: extra.step, error: p.error }
@@ -43,6 +75,18 @@ function summarizePreview(p, extra = {}) {
43
75
  }
44
76
  }
45
77
 
78
+ async function resolveLogoUri() {
79
+ const uri = opt('logo-uri')
80
+ const file = opt('logo') || opt('logo-file') || opt('file-logo')
81
+ if (uri && file) fail('use either --logo-uri or --logo/--logo-file, not both')
82
+ if (uri) return { logoURI: uri, uploaded: false }
83
+ if (file) {
84
+ const up = await uploadLogoFile(file)
85
+ return { logoURI: up.logoUrl, uploaded: true, upload: up }
86
+ }
87
+ return { logoURI: '', uploaded: false }
88
+ }
89
+
46
90
  async function main() {
47
91
  switch (cmd) {
48
92
  case 'preflight': {
@@ -55,13 +99,97 @@ async function main() {
55
99
  if (!settings.devMode?.enabled) issues.push({ code: 'DEV_MODE_OFF', fix: 'Enable Developer Mode in Binance App → Agentic Wallet settings.' })
56
100
  if (!settings.tradeAllTokens) issues.push({ code: 'TRADE_ALL_TOKENS_OFF', fix: 'Enable "Trade all tokens" in Binance App → Agentic Wallet settings, otherwise SELLING Gang tokens is blocked by risk control (buying still works).' })
57
101
  agent = await isAgent(me)
58
- if (!agent) issues.push({ code: 'NO_AGENT_ID', fix: 'Claim a .ai Agent ID in Binance Wallet (Agent ID banner). Gang only lets Agent ID holders buy.' })
102
+ if (!agent) issues.push({ code: 'NO_AGENT_ID', fix: 'Claim a .ai Agent ID in Binance Wallet (Agent ID banner). Gang only lets Agent ID holders buy/create.' })
59
103
  }
60
- return out({ success: issues.length === 0, status, address: me, launchpad: CONFIG.launchpad, isAgent: agent, devMode: settings?.devMode?.enabled ?? null, tradeAllTokens: settings?.tradeAllTokens ?? null, issues })
104
+ return out({
105
+ success: issues.length === 0, status, address: me,
106
+ launchpad: CONFIG.launchpad, createPad: ADDR.v2, isAgent: agent,
107
+ devMode: settings?.devMode?.enabled ?? null, tradeAllTokens: settings?.tradeAllTokens ?? null, issues,
108
+ note: 'New create → LaunchpadV2 only (~15 BNB graduation). Soft/U/S: existing buy/sell/claim only.',
109
+ })
61
110
  }
62
111
  case 'list': {
63
112
  const me = bawStatus() === 'CONNECTED' ? bawAddress() : undefined
64
- return out({ success: true, launchpad: CONFIG.launchpad, tokens: await listTokens(me) })
113
+ return out({ success: true, createPad: ADDR.v2, tokens: await listTokens(me) })
114
+ }
115
+ case 'hot':
116
+ case 'new':
117
+ case 'near-grad': {
118
+ const limit = parseCount('limit', opt('limit', '10'), 100)
119
+ const minPct = parsePct(opt('min-pct', '50'))
120
+ const maxAgeSec = opt('max-age') ? parseDuration(opt('max-age'), 'max-age') : null
121
+ const track = opt('track', 'all')
122
+ const includeGraduated = flag('include-graduated')
123
+ const market = await fetchMarket(track)
124
+ const tokens = rank(market.tokens, cmd, { limit, minPct, maxAgeSec, includeGraduated })
125
+ const empty = {
126
+ hot: includeGraduated ? 'No token traded in the last 24h.' : 'No curve token traded in the last 24h.',
127
+ new: maxAgeSec != null ? `No curve token created in the last ${opt('max-age')}.` : 'No curve tokens.',
128
+ 'near-grad': `No curve token at ≥${minPct}% of graduation; lower --min-pct to see the closest ones.`,
129
+ }[cmd]
130
+ return out({
131
+ success: true, source: API, sort: cmd, track, count: tokens.length,
132
+ ...(cmd === 'near-grad' ? { minPct } : {}), ...(maxAgeSec != null ? { maxAgeSec } : {}),
133
+ ...(market.truncated ? { truncated: true } : {}),
134
+ tokens,
135
+ note: tokens.length ? 'Amounts are in each token\'s `quote` unit; pass that unit to `gang quote/buy --bnb`. Data, not advice.' : empty,
136
+ })
137
+ }
138
+ case 'watch': {
139
+ const positional = args[1] && !args[1].startsWith('--') ? args[1] : null
140
+ if (positional && flag('token')) fail('give the token either as <addr> or via --token, not both')
141
+ if (!positional && flag('token') && !opt('token')) fail('--token needs an address (or omit it to watch the whole market)')
142
+ const token = positional || opt('token')
143
+ if (token && !/^0x[0-9a-fA-F]{40}$/.test(token)) fail('watch: token must be a 0x… address', { got: token })
144
+ // market mode pulls the full token list every tick, so it gets a higher floor than a single token
145
+ const intervalSec = Math.max(token ? 3 : 10, parseDuration(opt('interval', '10'), 'interval'))
146
+ const maxEvents = opt('max-events') ? parseCount('max-events', opt('max-events'), 1e6) : Infinity
147
+ const deadline = opt('timeout') ? Date.now() + parseDuration(opt('timeout'), 'timeout') * 1000 : Infinity
148
+ const minPct = parsePct(opt('min-pct', '50'))
149
+ const webhook = opt('webhook')
150
+ if (webhook && !/^https?:\/\//.test(webhook)) fail('--webhook must be an http(s) URL', { got: webhook })
151
+ let events = null
152
+ if (opt('events')) {
153
+ events = new Set(opt('events').split(',').map((s) => s.trim().replace(/-/g, '_')).filter(Boolean))
154
+ const bad = [...events].filter((e) => !WATCH_EVENTS.includes(e))
155
+ if (bad.length) fail(`unknown --events: ${bad.join(', ')}`, { allowed: WATCH_EVENTS })
156
+ }
157
+
158
+ let count = 0
159
+ const hooks = new Set()
160
+ const line = (o) => console.log(JSON.stringify({ ...o, emittedAt: Math.floor(Date.now() / 1000) }))
161
+ // meta events (watching / error / ended / timeout) go to stdout only and do not count toward --max-events
162
+ const emit = (ev, { meta = false } = {}) => {
163
+ line(ev)
164
+ if (meta) return
165
+ count++
166
+ if (webhook) {
167
+ const p = fetch(webhook, {
168
+ method: 'POST', headers: { 'content-type': 'application/json' },
169
+ body: JSON.stringify(ev), signal: AbortSignal.timeout(10_000),
170
+ }).then((r) => { if (!r.ok) line({ event: 'error', error: `webhook ${r.status}` }) })
171
+ .catch((e) => line({ event: 'error', error: `webhook: ${e.message}` }))
172
+ .finally(() => hooks.delete(p))
173
+ hooks.add(p)
174
+ }
175
+ }
176
+ // aborts in-flight API requests at the deadline so --timeout isn't stretched by a slow API.
177
+ // setTimeout caps at 2^31-1 ms (~24.8d) and fires at once beyond it; longer timeouts rely on sleep's check.
178
+ const stopper = new AbortController()
179
+ const delay = deadline - Date.now()
180
+ const deadlineTimer = delay <= 2 ** 31 - 1 ? setTimeout(() => stopper.abort(), delay) : null
181
+ // the timer runs on the monotonic clock and may beat Date.now() by a hair; treat its firing as the deadline
182
+ const shouldStop = () => count >= maxEvents || stopper.signal.aborted || Date.now() >= deadline
183
+ try {
184
+ await watch({ token, intervalSec, minPct, track: opt('track', 'all'), events, emit, shouldStop, stop: stopper.signal })
185
+ } catch (e) {
186
+ if (!shouldStop()) throw e // startup failure → fail() prints it as one NDJSON line
187
+ } finally {
188
+ clearTimeout(deadlineTimer)
189
+ }
190
+ if (count < maxEvents && (stopper.signal.aborted || Date.now() >= deadline)) line({ event: 'timeout', events: count })
191
+ await Promise.allSettled([...hooks])
192
+ return
65
193
  }
66
194
  case 'token': {
67
195
  const token = opt('token'); if (!token) fail('--token required')
@@ -69,10 +197,17 @@ async function main() {
69
197
  return out({ success: true, ...(await tokenInfo(token, me)) })
70
198
  }
71
199
  case 'quote': {
72
- const token = opt('token'), bnb = opt('bnb'); if (!token || !bnb) fail('--token and --bnb required')
200
+ const token = opt('token'), bnb = opt('bnb'); if (!token || !bnb) fail('--token and --bnb required (Soft archive: BNB; V2: quote ERC-20 amount)')
73
201
  const me = bawAddress()
74
202
  const q = await quoteBuy(token, me, bnb)
75
- return out({ success: true, token, buyer: me, tokensOut: q.tokensOutFmt, bnbSpent: q.bnbInFmt, fee: q.feeFmt, refund: q.refundFmt, note: q.refund > 0n ? 'Part of the BNB would be refunded (wallet 1% cap or graduation cap).' : undefined })
203
+ return out({
204
+ success: true, token, buyer: me, pad: q.pad, kind: q.kind, quote: q.quote,
205
+ quotePath: q.quotePath, deterministic: q.deterministic || false,
206
+ tokensOut: q.tokensOutFmt, spent: q.spentFmt, fee: q.feeFmt, refund: q.refundFmt, unit: q.unit,
207
+ note: q.refund > 0n
208
+ ? 'Part of the input would be refunded (wallet 1% cap or graduation cap).'
209
+ : (q.deterministic ? 'V2 quote = LaunchpadV2.sol curve math (unique path; no Soft/U/S fallback).' : undefined),
210
+ })
76
211
  }
77
212
  case 'buy': {
78
213
  const token = opt('token'), bnb = opt('bnb'); if (!token || !bnb) fail('--token and --bnb required')
@@ -81,12 +216,38 @@ async function main() {
81
216
  const info = await tokenInfo(token, me)
82
217
  if (info.graduated) fail('token already graduated; trade it on PancakeSwap', { pair: info.pair })
83
218
  if (!(await isAgent(me))) fail('this wallet holds no Agent ID (.ai); buying is gated', { address: me })
219
+ const pad = await resolvePad(token)
84
220
  const q = await quoteBuy(token, me, bnb)
85
- if (q.tokensOut === 0n) fail('wallet cap reached: this wallet already holds 1% of supply', { myBalance: info.myBalance })
221
+ if (q.tokensOut === 0n) fail('wallet cap reached or zero out', { myBalance: info.myBalance })
86
222
  const minOut = (q.tokensOut * BigInt(Math.round((100 - slippage) * 100))) / 10000n
87
- const data = calldata.buy(token, minOut)
88
- const p = bawPreview({ from: me, to: CONFIG.launchpad, value: parseEther(bnb), data })
89
- return out(summarizePreview(p, { step: 'buy', token, symbol: info.symbol, bnbSent: bnb, expectTokens: q.tokensOutFmt, expectBnbSpent: q.bnbInFmt, fee: q.feeFmt, expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut) }))
223
+
224
+ if (pad.kind === 'soft') {
225
+ const data = calldata.softBuy(token, minOut)
226
+ const p = bawPreview({ from: me, to: pad.address, value: parseEther(bnb), data })
227
+ return out(summarizePreview(p, {
228
+ step: 'buy', kind: 'soft', token, symbol: info.symbol, pad: pad.id,
229
+ bnbSent: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt,
230
+ expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut),
231
+ }))
232
+ }
233
+
234
+ // V2 / U / S: quote ERC-20 approve then buy(token, quoteIn, minTokensOut)
235
+ const quoteIn = parseEther(bnb)
236
+ const allow = await allowance(info.quote, me, pad.address)
237
+ if (allow < quoteIn) {
238
+ const p = bawPreview({ from: me, to: info.quote, data: calldata.approve(pad.address, quoteIn) })
239
+ return out(summarizePreview(p, {
240
+ step: 'approve-quote', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
241
+ amount: bnb, note: 'Approve quote ERC-20 first. After it confirms, re-run the same `gang buy` to preview the buy.',
242
+ }))
243
+ }
244
+ const data = calldata.v2Buy(token, quoteIn, minOut)
245
+ const p = bawPreview({ from: me, to: pad.address, data })
246
+ return out(summarizePreview(p, {
247
+ step: 'buy', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
248
+ quoteIn: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt,
249
+ expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut), quotePath: q.quotePath, deterministic: q.deterministic || false,
250
+ }))
90
251
  }
91
252
  case 'sell': {
92
253
  const token = opt('token'); if (!token) fail('--token required')
@@ -99,22 +260,56 @@ async function main() {
99
260
  if (flag('all') && opt('amount')) fail('use either --all or --amount, not both')
100
261
  const amount = flag('all') ? bal : parseEther(opt('amount'))
101
262
  if (amount > bal) fail('amount exceeds balance', { balance: fmtTok(bal) })
102
- const allow = await allowance(token, me)
263
+ const pad = await resolvePad(token)
264
+ const allow = await allowance(token, me, pad.address)
103
265
  if (allow < amount) {
104
- const p = bawPreview({ from: me, to: token, data: calldata.approve(amount) })
105
- return out(summarizePreview(p, { step: 'approve', token, symbol: info.symbol, amount: fmtTok(amount), note: 'Approve first. After it confirms on-chain, run the same `gang sell` again to preview the sell.' }))
266
+ const p = bawPreview({ from: me, to: token, data: calldata.approve(pad.address, amount) })
267
+ return out(summarizePreview(p, {
268
+ step: 'approve', token, symbol: info.symbol, pad: pad.id, amount: fmtTok(amount),
269
+ note: 'Approve first. After it confirms on-chain, run the same `gang sell` again to preview the sell.',
270
+ }))
106
271
  }
107
272
  const slippage = parseSlippage(opt('slippage', '1'))
108
273
  const q = await quoteSell(token, amount)
109
- const minOut = (q.bnbToUser * BigInt(Math.round((100 - slippage) * 100))) / 10000n
110
- const p = bawPreview({ from: me, to: CONFIG.launchpad, data: calldata.sell(token, amount, minOut) })
111
- return out(summarizePreview(p, { step: 'sell', token, symbol: info.symbol, amount: fmtTok(amount), expectBnb: q.bnbToUserFmt, fee: q.feeFmt, minBnbOut: fmt(minOut) }))
274
+ const minOut = (q.out * BigInt(Math.round((100 - slippage) * 100))) / 10000n
275
+ if (pad.kind === 'soft') {
276
+ const p = bawPreview({ from: me, to: pad.address, data: calldata.softSell(token, amount, minOut) })
277
+ return out(summarizePreview(p, {
278
+ step: 'sell', kind: 'soft', token, symbol: info.symbol, pad: pad.id,
279
+ amount: fmtTok(amount), expectOut: q.outFmt, fee: q.feeFmt, minOut: fmt(minOut), unit: 'BNB',
280
+ }))
281
+ }
282
+ const p = bawPreview({ from: me, to: pad.address, data: calldata.v2Sell(token, amount, minOut) })
283
+ return out(summarizePreview(p, {
284
+ step: 'sell', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
285
+ amount: fmtTok(amount), expectOut: q.outFmt, fee: q.feeFmt, minOut: fmt(minOut), unit: 'quote', quotePath: q.quotePath, deterministic: q.deterministic || false,
286
+ }))
287
+ }
288
+ case 'upload-logo': {
289
+ const file = opt('file') || opt('path') || opt('logo')
290
+ if (!file) fail('--file <path> required (png/jpeg/webp/gif ≤512KB)')
291
+ const up = await uploadLogoFile(file)
292
+ return out({ success: true, ...up, ui: LOGO_UI, next: `gang create --name … --symbol … --logo-uri ${up.logoUrl}` })
112
293
  }
113
294
  case 'create': {
114
295
  const name = opt('name'), symbol = opt('symbol'); if (!name || !symbol) fail('--name and --symbol required')
296
+ try { assertCreateTarget(CONFIG.launchpad) } catch (e) { fail(e.message) }
115
297
  const me = bawAddress()
116
- const p = bawPreview({ from: me, to: CONFIG.launchpad, data: calldata.create(name, symbol) })
117
- return out(summarizePreview(p, { step: 'create', name, symbol, note: 'No creation fee. The token address appears in the Created event: run `gang tx --hash <txHash>` after execute.' }))
298
+ if (!(await isAgent(me))) fail('this wallet holds no Agent ID (.ai); create is gated', { address: me })
299
+ const logo = await resolveLogoUri()
300
+ const taxed = flag('taxed')
301
+ const pairOrQuote = opt('pair-or-quote') || opt('pair') || zeroAddress
302
+ if (taxed && (!pairOrQuote || pairOrQuote === zeroAddress)) {
303
+ fail('--taxed requires --pair-or-quote <pair or quote CA>')
304
+ }
305
+ const data = calldata.createV2(name, symbol, logo.logoURI, taxed, pairOrQuote)
306
+ const p = bawPreview({ from: me, to: ADDR.v2, data })
307
+ return out(summarizePreview(p, {
308
+ step: 'create', pad: 'v2', launchpad: ADDR.v2, name, symbol,
309
+ logoURI: logo.logoURI || '(empty)', uploaded: logo.uploaded, upload: logo.upload,
310
+ taxed, pairOrQuote,
311
+ note: 'LaunchpadV2 only. Graduation ≈15 BNB (PoolCheck). No creation fee. Soft/U/S create archived. After execute: `gang tx --hash …` for Created.',
312
+ }))
118
313
  }
119
314
  case 'execute': {
120
315
  const id = opt('request-id'); if (!id) fail('--request-id required')
@@ -122,7 +317,12 @@ async function main() {
122
317
  if (!r.success) return out({ success: false, error: r.error })
123
318
  const d = r.data
124
319
  const pending = d.status === 'PENDING_CONFIRMATION'
125
- return out({ success: true, status: d.status, txHash: d.txHash, orderId: d.orderId, pendingConfirmation: pending, next: pending ? '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>`.' : (d.txHash ? `gang tx --hash ${d.txHash}` : undefined) })
320
+ return out({
321
+ success: true, status: d.status, txHash: d.txHash, orderId: d.orderId, pendingConfirmation: pending,
322
+ next: pending
323
+ ? '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
+ : (d.txHash ? `gang tx --hash ${d.txHash}` : undefined),
325
+ })
126
326
  }
127
327
  case 'tx': {
128
328
  const hash = opt('hash'); if (!hash) fail('--hash required')
package/src/logo.js ADDED
@@ -0,0 +1,40 @@
1
+ import { readFileSync, statSync } from 'node:fs'
2
+ import { extname, resolve } from 'node:path'
3
+
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
+ }
12
+
13
+ export const LOGO_API = process.env.GANG_LOGO_API || 'https://gang.fun/api/logos'
14
+ export const LOGO_UI = 'https://gang.fun/dev/logo'
15
+
16
+ /** Read a local image → data URL → POST /api/logos → { logoUrl, id, bytes }. */
17
+ export async function uploadLogoFile(filePath) {
18
+ const abs = resolve(filePath)
19
+ const st = statSync(abs)
20
+ if (!st.isFile()) throw new Error(`not a file: ${abs}`)
21
+ if (st.size <= 0) throw new Error(`empty file: ${abs}`)
22
+ 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)}`)
38
+ }
39
+ return { logoUrl: json.logoUrl, id: json.id, bytes: json.bytes ?? st.size, api: LOGO_API }
40
+ }