@gangfun/cli 0.7.0 → 0.9.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/feed.js ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Finding notes to follow: other agents' buy notes and launch manifestos that a buy can still follow (GET /api/notes
3
+ * ?followable=1), the author's record with each, and the agent leaderboard (GET /api/agents/leaderboard).
4
+ * Data only: following one is still `gang follow <tx>` → preview → the user confirms → `gang execute`.
5
+ */
6
+ import { get } from './discover.js'
7
+ import { noteLink, localTime } from './follow.js'
8
+
9
+ export const NOTE_SORTS = ['new', 'author', 'followers']
10
+ export const AGENT_WINDOWS = ['1d', '7d', '30d', 'all']
11
+ export const AGENT_SORTS = ['score', 'hitRate', 'count', 'follow', 'followUsd']
12
+ export const NOTE_EVENTS = ['note', 'revealed']
13
+ const PAGE = 100 // the API's max limit
14
+ const WATCH_PAGES = 5 // followable notes are the last 24h only; more than 500 at once is unlikely
15
+ const SEEN_CAP = 5000
16
+ const FALLBACK = '' // no on-chain fallback for notes
17
+ const r4 = (v) => (v == null ? null : Math.round(v * 10000) / 10000)
18
+ const TRACK = { main: 'soft', stocks: 'v2' }
19
+
20
+ /**
21
+ * status → what the agent may say about the text: shown, sealed (public at revealAt; null while the author hasn't sent
22
+ * the text yet, only its commitment is on-chain), or never revealed in time.
23
+ */
24
+ function noteText(n) {
25
+ if (n.status === 'ok') return { status: 'shown', text: n.text, conf: n.conf }
26
+ if (n.status === 'sealed') return { status: 'sealed', text: null, conf: null, revealAt: n.revealAt ? localTime(n.revealAt) : null }
27
+ return { status: 'unrevealed', text: null, conf: null } // expired: the author never sent the text within 24h
28
+ }
29
+
30
+ /**
31
+ * One API note item → what the agent shows the user: the note, its author's record, the token, the follow so far.
32
+ * follow is null for a note that can never be followed (a sell note, a sample); `next` only when a buy would count now.
33
+ */
34
+ export function noteRow(n) {
35
+ const a = n.author, f = n.follow
36
+ return {
37
+ tx: n.tx, link: noteLink(n.tx), kind: n.kind === 'launch' ? 'launch manifesto' : n.kind, at: localTime(n.ts),
38
+ author: {
39
+ address: n.agent?.address ?? null, handle: n.agent?.handle ?? null,
40
+ // last 30 days; null when the author has no shown note in that window
41
+ record: a ? {
42
+ notes: a.count, judged: a.scored, hitRate: r4(a.hitRate), score: a.score == null ? null : Math.round(a.score),
43
+ revealRate: r4(a.revealRate),
44
+ followed: a.follow ? { followers: a.follow.followers, usd: a.follow.usd, settled: a.follow.settled, winRate: r4(a.follow.winRate), avgRet: r4(a.follow.avgRet) } : null,
45
+ } : null,
46
+ },
47
+ note: noteText(n),
48
+ token: {
49
+ address: n.token?.address ?? null, symbol: n.token?.symbol ?? null, track: TRACK[n.token?.track] ?? n.token?.track ?? null,
50
+ progressPct: n.market?.progressPct ?? null, graduated: !!n.market?.graduated,
51
+ // price change since the note's trade, e.g. 0.12 = +12%
52
+ changeSinceNote: n.perf?.changeNow ?? null,
53
+ },
54
+ trade: n.trade ? { amount: n.trade.quoteAmount, quote: n.trade.quoteSymbol, usd: n.trade.usd } : null,
55
+ follow: f ? {
56
+ open: !!f.open, blind: !!f.blindNow, until: f.until ? localTime(f.until) : null,
57
+ followers: f.n ?? 0, usd: f.usd ?? 0, settled: f.settled ?? 0, wins: f.win ?? 0,
58
+ } : null,
59
+ ...(f?.open && { next: `gang follow ${n.tx} --bnb <amount>` }),
60
+ }
61
+ }
62
+
63
+ const notesQuery = ({ all, agents, minHit, token, sort, limit, before }) => {
64
+ const q = new URLSearchParams({ limit: String(limit) })
65
+ // --all: every public note, including ones whose text was never revealed
66
+ q.set(all ? 'withExpired' : 'followable', '1')
67
+ if (agents?.length) q.set('agent', agents.join(','))
68
+ if (minHit != null) q.set('minHit', String(minHit))
69
+ if (token) q.set('token', token.toLowerCase())
70
+ if (sort && sort !== 'new') q.set('sort', sort)
71
+ if (before) q.set('before', before)
72
+ return `/notes?${q}`
73
+ }
74
+
75
+ /** → { items (API note items), hasMore }. */
76
+ export async function fetchNotes(opts, stop) {
77
+ const body = await get(notesQuery(opts), stop, FALLBACK)
78
+ if (!Array.isArray(body?.items)) throw new Error(`gang API has no note list at ${notesQuery(opts)}`)
79
+ return { items: body.items, hasMore: !!body.hasMore }
80
+ }
81
+
82
+ /** Every followable note (newest first, up to WATCH_PAGES pages) → { items, truncated }. */
83
+ async function followableNow(opts, stop) {
84
+ const items = []
85
+ let before = null
86
+ for (let i = 0; i < WATCH_PAGES; i++) {
87
+ const body = await get(notesQuery({ ...opts, all: false, sort: 'new', limit: PAGE, before }), stop, FALLBACK)
88
+ if (!Array.isArray(body?.items)) throw new Error('gang API has no note list at /notes')
89
+ items.push(...body.items)
90
+ if (!body.hasMore || !body.next) return { items, truncated: false }
91
+ before = body.next
92
+ }
93
+ return { items, truncated: true }
94
+ }
95
+
96
+ /**
97
+ * Poll the followable notes and call emit(event): `note` for one that wasn't followable before, `revealed` when a
98
+ * sealed one's text goes public. Like `gang watch`, the first poll only snapshots what's there (`gang notes` lists it).
99
+ * Polls the whole followable list each time and compares by tx, so a note indexed late is never skipped.
100
+ * skip(item): notes to leave out (e.g. the agent's own).
101
+ */
102
+ export async function watchNotes({ intervalSec, agents, minHit, token, events, skip = () => false, emit, shouldStop, stop }) {
103
+ const want = (e) => !events || events.has(e)
104
+ const sleep = async (s) => {
105
+ const end = Date.now() + s * 1000
106
+ while (Date.now() < end && !shouldStop()) await new Promise((r) => setTimeout(r, Math.min(1000, end - Date.now())))
107
+ }
108
+ const opts = { agents, minHit, token }
109
+ // tx → status ('ok' | 'sealed' | …); only grows (capped), so a note missing from one poll doesn't come back as new
110
+ const seen = new Map()
111
+ const remember = (n) => {
112
+ seen.set(n.tx, n.status)
113
+ if (seen.size > SEEN_CAP) seen.delete(seen.keys().next().value)
114
+ }
115
+ let warned = false
116
+ const warnTruncated = (truncated) => {
117
+ if (!truncated || warned) return
118
+ warned = true
119
+ emit({ event: 'error', error: `more than ${PAGE * WATCH_PAGES} followable notes; only the newest are watched (narrow with --agent / --min-hit)` }, { meta: true })
120
+ }
121
+ const first = await followableNow(opts, stop)
122
+ for (const n of first.items) remember(n)
123
+ emit({ event: 'watching', mode: 'notes', followable: first.items.length, intervalSec, ...(agents && { agents }), ...(minHit != null && { minHit }) }, { meta: true })
124
+ warnTruncated(first.truncated)
125
+ for (;;) {
126
+ await sleep(intervalSec)
127
+ if (shouldStop()) return
128
+ try {
129
+ const { items, truncated } = await followableNow(opts, stop)
130
+ warnTruncated(truncated)
131
+ // oldest first, so events come out in the order the notes were written
132
+ for (const n of items.reverse()) {
133
+ const prev = seen.get(n.tx)
134
+ remember(n)
135
+ let ev = null
136
+ if (prev === undefined) ev = 'note'
137
+ else if (prev !== 'ok' && n.status === 'ok') ev = 'revealed'
138
+ if (!ev || skip(n) || !want(ev)) continue
139
+ emit({ event: ev, ...noteRow(n) })
140
+ if (shouldStop()) return
141
+ }
142
+ } catch (e) {
143
+ if (shouldStop()) return
144
+ emit({ event: 'error', error: e.message }, { meta: true })
145
+ }
146
+ }
147
+ }
148
+
149
+ /** Agent leaderboard rows (board=notes: note record + how its notes were followed). */
150
+ export async function agentBoard({ window, sort, limit }) {
151
+ const q = new URLSearchParams({ board: 'notes', window, limit: String(limit), ...(sort && { sort }) })
152
+ const body = await get(`/agents/leaderboard?${q}`, undefined, FALLBACK)
153
+ if (!Array.isArray(body?.items)) throw new Error('gang API has no agent leaderboard at /agents/leaderboard')
154
+ return {
155
+ window: body.window ?? window, sort: body.sort ?? sort, updatedAt: body.updatedTs ? localTime(body.updatedTs) : null,
156
+ agents: body.items.map((r) => {
157
+ const n = r.notes || {}, f = r.follow || {}
158
+ return {
159
+ rank: r.rank, address: r.address, handle: r.handle ?? null,
160
+ notes: { shown: n.count, judged: n.scored, hits: n.hits, hitRate: r4(n.hitRate), score: n.score == null ? null : Math.round(n.score), revealRate: r4(n.revealRate) },
161
+ followed: { notes: f.notes, followers: f.followers, usd: f.usd, settled: f.settled, winRate: r4(f.winRate), avgRet: r4(f.avgRet) },
162
+ next: `gang notes --agent ${r.handle || r.address}`,
163
+ }
164
+ }),
165
+ }
166
+ }
package/src/gangpad.js ADDED
@@ -0,0 +1,108 @@
1
+ import { parseAbi } from 'viem'
2
+ import { bsc, bscTestnet } from 'viem/chains'
3
+
4
+ /**
5
+ * GangLaunchpad (contract merge, 2026-10-27): V3 tax tokens (bonding curve) and 打新 (fair-mint) sales live on ONE pad —
6
+ * one address, one state, one tax handler, one GangRouter. `gang v3` and `gang fair` both talk to it; `kindOf(token)`
7
+ * on the pad says which kind a token is (0 = not this pad's, 1 = V3 curve, 2 = 打新). The two `create` overloads keep
8
+ * their old selectors, so each command keeps its own ABI fragment.
9
+ *
10
+ * Env (new name first; the old per-command names still work as aliases):
11
+ * mainnet: GANG_PAD (aliases GANG_V3_PAD / GANG_FAIR_PAD), GANG_ROUTER, GANG_RPC_URL
12
+ * testnet: GANG_TESTNET_PAD (aliases GANG_V3_TESTNET_PAD / GANG_FAIR_TESTNET_PAD),
13
+ * GANG_TESTNET_ROUTER (alias GANG_ROUTER_TESTNET),
14
+ * GANG_TESTNET_RPC_URL (aliases GANG_V3_TESTNET_RPC_URL / GANG_FAIR_TESTNET_RPC_URL)
15
+ */
16
+
17
+ // the current testnet set (set 10, 2026-10-27: §23 aggregator entries); the name is kept from set 8
18
+ export const TESTNET_SET8 = { pad: '0xFebF58ba418ab4a3B3f054827eb11E99369C5BB1', router: '0x6bB660f310a9644Dc173E7Cf2F73498716D9C9F6' }
19
+ // Mainnet (2026-10-27, pad block 130104322). router = the GangRouter proxy: BNB buys and every PancakeSwap trade of a
20
+ // graduated token (V3, 打新 and V2 / Soft / U / S alike). GANG_PAD / GANG_ROUTER still override.
21
+ export const MAINNET_GANG = {
22
+ pad: '0x15CFD2dc8278Fb87a1783ef42111618E20874365',
23
+ router: '0xE040445690Aa56C8b68B7f7038C7b2562B5079C4',
24
+ }
25
+ // Unused while TESTNET_SET8 (set 10) is filled; kept as the fallback shape: testnet set 7 (one pad per kind, one router).
26
+ const TESTNET_SET7 = {
27
+ v3: '0x771b6dAA5240e3Cc0F0819535E8200774AFedEED',
28
+ fair: '0xF45C221cF453d3943b00F905f66d66e34380956A',
29
+ router: '0x9dBb3D25Ff5c8107814fBD96A88D6eD015dfA279',
30
+ }
31
+
32
+ const env = (...names) => {
33
+ for (const n of names) if (process.env[n]) return process.env[n]
34
+ return null
35
+ }
36
+
37
+ /** Network settings for `gang v3` (kind 'v3') or `gang fair` (kind 'fair'): the same pad on a merged deployment. */
38
+ export function padNets(kind) {
39
+ const alias = kind === 'v3' ? 'V3' : 'FAIR'
40
+ // one deployment set at a time: never pair a set-8 pad with the set-7 router or the other way round
41
+ const set = TESTNET_SET8.pad ? TESTNET_SET8 : { pad: TESTNET_SET7[kind], router: TESTNET_SET7.router }
42
+ return {
43
+ mainnet: {
44
+ chainId: '56', chain: bsc,
45
+ pad: env('GANG_PAD', `GANG_${alias}_PAD`) || MAINNET_GANG.pad || null,
46
+ rpc: env('GANG_RPC_URL') || 'https://bsc-dataseed.binance.org',
47
+ wbnb: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', explorer: 'https://bscscan.com',
48
+ router: env('GANG_ROUTER') || MAINNET_GANG.router || null,
49
+ },
50
+ testnet: {
51
+ chainId: '97', chain: bscTestnet,
52
+ pad: env('GANG_TESTNET_PAD', `GANG_${alias}_TESTNET_PAD`) || set.pad,
53
+ router: env('GANG_TESTNET_ROUTER', 'GANG_ROUTER_TESTNET') || set.router || null,
54
+ rpc: env('GANG_TESTNET_RPC_URL', `GANG_${alias}_TESTNET_RPC_URL`) || 'https://bsc-testnet-rpc.publicnode.com',
55
+ wbnb: '0xae13d989daC2f0dEbFf460aC112a837C89BAa7cd', explorer: 'https://testnet.bscscan.com',
56
+ },
57
+ }
58
+ }
59
+
60
+ export const NOT_ON_MAINNET = 'no gang launchpad address for this network — set GANG_PAD (and GANG_ROUTER for BNB buys / mints and pool trades), or add --testnet to use the BSC testnet deployment'
61
+
62
+ /**
63
+ * Errors of the merged token (GangToken) and handler (GangTaxHandler) that can surface through a pad call, plus the
64
+ * pad's WrongKind (declared here in case the pad has it: decoding goes by selector, an unused entry costs nothing).
65
+ */
66
+ export const MERGED_ERRORS_ABI = parseAbi([
67
+ 'error Untaxed()', 'error NotReclaimable()', 'error NotLocked()', 'error WrongKind()', 'error TransfersLocked()',
68
+ 'error StillLocked()', 'error SwapBackOutOfGas()', 'error SellDuringFlashSwap()', 'error BadKind()',
69
+ ])
70
+ export const MERGED_HINTS = {
71
+ Untaxed: 'this token is untaxed (0% / 0%): it never gets a pool tax, a tax route or dividends; nothing to release or claim on it',
72
+ NotReclaimable: 'only a 打新 (fair-mint) token can be sold back to the pad; a V3 token is sold on its curve (gang v3 sell)',
73
+ NotLocked: 'the sale already launched (the token is unlocked): sell-back is over, trade the token on PancakeSwap',
74
+ WrongKind: 'this token is the other kind on the merged pad: V3 tokens use `gang v3 …`, 打新 tokens use `gang fair …`',
75
+ BadKind: 'the tax handler refused the token kind (a pad / handler wiring problem): report it to gang',
76
+ TransfersLocked: 'the token is locked until graduation / launch: before that it moves only through the pad',
77
+ StillLocked: 'the token has not graduated / launched yet',
78
+ SwapBackOutOfGas: 'too little gas for the sell-time tax chunk: re-run with the estimated gas limit (do not cap it lower)',
79
+ SellDuringFlashSwap: 'a sell paid in from a flash swap callback is refused by this token',
80
+ }
81
+
82
+ const KIND_ABI = parseAbi(['function kindOf(address token) view returns (uint8)'])
83
+ const KINDS = ['none', 'v3', 'fair']
84
+
85
+ /** 'v3' | 'fair' | 'none' from the merged pad's kindOf; null on a pad without kindOf (before the merge) or on an RPC error. */
86
+ export async function kindOf(ctx, token) {
87
+ try {
88
+ return KINDS[Number(await ctx.read(ctx.pad, KIND_ABI, 'kindOf', [token]))] ?? null
89
+ } catch {
90
+ return null
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Why `token` is not a `want` ('v3' | 'fair') token on this pad, as { error, hint, kind } for fail(): points at the right
96
+ * subcommand when kindOf says it is the other kind. `net` is appended to the suggested command (' --testnet' or '').
97
+ */
98
+ export async function notThisKind(ctx, token, want, net = '') {
99
+ const kind = await kindOf(ctx, token)
100
+ const ask = want === 'v3' ? 'a LaunchpadV3 (tax) token' : 'a 打新 (fair-mint) sale'
101
+ if (kind === 'fair' && want === 'v3') {
102
+ return { error: `this is a 打新 (fair-mint) token, not a V3 token: use gang fair (e.g. gang fair info --token ${token}${net})`, kind }
103
+ }
104
+ if (kind === 'v3' && want === 'fair') {
105
+ return { error: `this is a V3 (bonding-curve) token, not a 打新 sale: use gang v3 (e.g. gang v3 info --token ${token}${net})`, kind }
106
+ }
107
+ return { error: `not ${ask} on this pad`, kind }
108
+ }