@gangfun/cli 0.12.0 → 0.14.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 +18 -1
- package/README.zh-CN.md +18 -1
- package/package.json +1 -1
- package/src/bond.js +564 -0
- package/src/buyback.js +205 -0
- package/src/fair.js +8 -1
- package/src/index.js +85 -1
- package/src/launch.js +297 -0
- package/src/pool.js +10 -1
- package/src/v3.js +11 -2
package/src/bond.js
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
import { createPublicClient, http, parseAbi, encodeFunctionData, decodeEventLog, formatUnits, parseUnits, isAddress, zeroAddress } from 'viem'
|
|
2
|
+
import { decimalsOf, fundingStep as fund, parseAmount, parseIntIn, parseNum, revertOf as revertIn, step, units, GAS_RESERVE } from './steps.js'
|
|
3
|
+
import { MERGED_ERRORS_ABI, MERGED_HINTS, NOT_ON_MAINNET, padNets } from './gangpad.js'
|
|
4
|
+
import { bawAddress } from './baw.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `gang bond …` — GangBond (Agent 押金承诺, gang-launchpad src/gang/GangBond.sol): a creator of a GangLaunchpad token
|
|
8
|
+
* posts a bond in the token's quote and promises its creating address holds at least X tokens. It can be posted any
|
|
9
|
+
* time after create (on the curve / during the 打新 sale too) and binds from then; T is `duration` after the token
|
|
10
|
+
* reaches its PancakeSwap pair (graduation / launch; `start` sets it). Below X, anyone can report it: the reporter gets
|
|
11
|
+
* 1%, the rest buys the token back from its pair in small chunks and burns it. Settle (after T, balance still ≥ X)
|
|
12
|
+
* returns the whole bond to the creator; refund does the same when the pair never came within 30 days of the post.
|
|
13
|
+
* One contract per network, no owner; it only reads the pad.
|
|
14
|
+
*
|
|
15
|
+
* Env: GANG_BOND (mainnet), GANG_TESTNET_BOND (testnet) override the addresses below.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// script/DeployGangBond.s.sol. Testnet: 2026-10-28, block 139374309, over the set-10 pad — bonds from create on, refunds
|
|
19
|
+
// asked for by the creator with a 3-day notice (earlier testnet versions: 0x587f…5d7E after the pair only, 0x5a99…d716
|
|
20
|
+
// without the notice; docs/TESTNET-GANG-BOND-2026-10-28.md). Mainnet: not deployed yet (GANG_BOND overrides either)
|
|
21
|
+
export const MAINNET_BOND = null
|
|
22
|
+
export const TESTNET_BOND = '0xB034cFeb81Cbd29EE3d0CD6a0885766B6F10EBE3'
|
|
23
|
+
|
|
24
|
+
export const BOND_ABI = parseAbi([
|
|
25
|
+
'function post(address token, uint256 amount, uint256 holdAmount, uint64 duration) payable',
|
|
26
|
+
'function topUp(address token, uint256 amount) payable',
|
|
27
|
+
'function raiseHold(address token, uint256 newHoldAmount)',
|
|
28
|
+
'function extend(address token, uint64 by)',
|
|
29
|
+
'function start(address token)',
|
|
30
|
+
'function report(address token)',
|
|
31
|
+
'function settle(address token)',
|
|
32
|
+
'function requestRefund(address token)',
|
|
33
|
+
'function refund(address token)',
|
|
34
|
+
'function burnChunk(address token, uint256 minTokensOut) returns (uint256 quoteIn, uint256 burned)',
|
|
35
|
+
'function bondOf(address token) view returns ((address creator, uint64 postedAt, uint32 round, address quote, uint8 state, uint64 deadline, uint64 duration, uint64 refundAt, uint128 amount, uint128 holdAmount, uint128 burnLeft))',
|
|
36
|
+
'function isViolated(address token) view returns (bool)',
|
|
37
|
+
'function isLive(address token) view returns (bool)',
|
|
38
|
+
'function dumpValue(address token) view returns (uint256)',
|
|
39
|
+
'function coverageBps(address token) view returns (uint256)',
|
|
40
|
+
'function historyOf(address creator) view returns (uint32 posted, uint32 honored, uint32 slashed)',
|
|
41
|
+
'function quoteDump(address token, uint256 holdAmount) view returns (uint256)',
|
|
42
|
+
'function previewBurn(address token) view returns (bool ready, uint256 quoteIn, uint256 tokensOut)',
|
|
43
|
+
'function MIN_DURATION() view returns (uint64)', 'function MAX_DURATION() view returns (uint64)',
|
|
44
|
+
'function REPORTER_BPS() view returns (uint256)', 'function MAX_WAIT() view returns (uint64)', 'function REFUND_NOTICE() view returns (uint64)',
|
|
45
|
+
'event BondPosted(address indexed token, address indexed creator, uint32 round, address quote, uint256 amount, uint256 holdAmount, uint64 duration, uint64 deadline)',
|
|
46
|
+
'event BondStarted(address indexed token, uint32 round, uint64 deadline)',
|
|
47
|
+
'event BondToppedUp(address indexed token, uint32 round, uint256 added, uint256 amount)',
|
|
48
|
+
'event HoldRaised(address indexed token, uint32 round, uint256 holdAmount)',
|
|
49
|
+
'event BondExtended(address indexed token, uint32 round, uint64 duration, uint64 deadline)',
|
|
50
|
+
'event BondReported(address indexed token, uint32 round, address indexed reporter, uint256 balance, uint256 reward, uint256 burnLeft)',
|
|
51
|
+
'event BondSettled(address indexed token, uint32 round, address indexed creator, uint256 amount)',
|
|
52
|
+
'event RefundRequested(address indexed token, uint32 round, uint64 refundAt)',
|
|
53
|
+
'event BondRefunded(address indexed token, uint32 round, address indexed creator, uint256 amount)',
|
|
54
|
+
'event BondBurned(address indexed token, uint32 round, uint256 quoteIn, uint256 tokensBurned, uint256 burnLeft)',
|
|
55
|
+
'error ZeroAddress()', 'error UnknownToken()', 'error NotTradable()', 'error NotCreator()', 'error BondLive()',
|
|
56
|
+
'error NotActive()', 'error ZeroAmount()', 'error BadDuration()', 'error BadHold()', 'error BadValue()',
|
|
57
|
+
'error FeeOnTransferQuote()', 'error NotViolated()', 'error Violated()', 'error NotDue()', 'error NothingToBurn()',
|
|
58
|
+
'error BurnedThisBlock()', 'error Slippage()', 'error FlashSwapActive()', 'error AlreadyLive()', 'error AlreadyRequested()', 'error NoRefundRequest()',
|
|
59
|
+
'error SafeCastOverflowedUintDowncast(uint8 bits, uint256 value)', 'error SafeERC20FailedOperation(address token)',
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
const PAD_ABI = parseAbi([
|
|
63
|
+
'function kindOf(address token) view returns (uint8)',
|
|
64
|
+
'function curves(address token) view returns (address creator, address quote, uint128 realQuote, uint128 sold, uint128 gradAmt, uint128 virtualQuote, bool graduated, uint16 buyTaxBps, uint16 sellTaxBps, uint16 dividendBps, address pair, address refPair)',
|
|
65
|
+
'function sales(address token) view returns (address creator, uint64 progressAt, uint16 capShares, uint16 holdBps, address quote, uint16 sold, bool launched, uint16 mintFeeBps, uint16 exitFeeBps, address gate, uint128 sharePrice, address pair, address refPool, uint16 markSold)',
|
|
66
|
+
])
|
|
67
|
+
const ERC20_ABI = parseAbi(['function balanceOf(address) view returns (uint256)', 'function symbol() view returns (string)'])
|
|
68
|
+
|
|
69
|
+
const HINTS = {
|
|
70
|
+
NotTradable: 'this needs the token on its PancakeSwap pair (V3 graduated / 打新 launched), and it is not there yet',
|
|
71
|
+
UnknownToken: 'not a GangLaunchpad token (V3 or 打新): bonds cover only those',
|
|
72
|
+
NotCreator: 'only the token\'s creator (curves / sales creator) can post, top up, raise X or extend',
|
|
73
|
+
BondLive: 'this token already has a bond in force (active, or slashed with buy-back still running): one at a time',
|
|
74
|
+
NotActive: 'no active bond on this token (none posted, already settled, refunded or slashed)',
|
|
75
|
+
BadDuration: '--days must be 7–90 (extend: before the pair at most 90 days in all, after it a deadline at most 90 days from now)',
|
|
76
|
+
BadHold: '--hold must be above 0 and at most the creator\'s balance now (raise-hold: not below the current X)',
|
|
77
|
+
BadValue: 'native BNB only for a WBNB-quoted token, exactly the bond amount',
|
|
78
|
+
FeeOnTransferQuote: 'the quote takes a fee on transfer: it cannot be used for a bond',
|
|
79
|
+
NotViolated: 'the creator still holds at least X: nothing to report',
|
|
80
|
+
Violated: 'the creator holds less than X: the bond cannot be settled or refunded, only reported',
|
|
81
|
+
AlreadyLive: 'the token reached its pair: the bond runs (gang bond start) instead of being refunded',
|
|
82
|
+
AlreadyRequested: 'a refund is already requested: it can run once its 3-day notice is over',
|
|
83
|
+
NoRefundRequest: 'request the refund first (gang bond refund asks for it; it runs 3 days later)',
|
|
84
|
+
NotDue: 'the deadline has not passed yet',
|
|
85
|
+
NothingToBurn: 'no slashed bond with buy-back left on this token',
|
|
86
|
+
BurnedThisBlock: 'a chunk was already bought for this token in this block: try the next block',
|
|
87
|
+
Slippage: 'the buy-back would burn fewer tokens than the minimum: the price moved, re-run',
|
|
88
|
+
FlashSwapActive: 'the token\'s pair is mid flash swap: settle cannot count tokens borrowed from it; send settle in its own tx',
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const STATES = ['none', 'active', 'slashed', 'settled', 'refunded']
|
|
92
|
+
const MAX_WAIT = 30 * 86_400 // GangBond.MAX_WAIT
|
|
93
|
+
const REFUND_NOTICE = 3 * 86_400 // GangBond.REFUND_NOTICE
|
|
94
|
+
const BPS = 10_000n
|
|
95
|
+
const TOTAL_SUPPLY = 1_000_000_000n * 10n ** 18n
|
|
96
|
+
const DAY = 86_400
|
|
97
|
+
const MAX_UINT = 2n ** 256n - 1n
|
|
98
|
+
|
|
99
|
+
/** GangBond address of `testnet` / mainnet: env first, then the shipped one; null when not deployed there. */
|
|
100
|
+
export function bondAddress(testnet) {
|
|
101
|
+
return (testnet ? process.env.GANG_TESTNET_BOND || TESTNET_BOND : process.env.GANG_BOND || MAINNET_BOND) || null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function bondCtx(testnet) {
|
|
105
|
+
const net = padNets('v3')[testnet ? 'testnet' : 'mainnet']
|
|
106
|
+
if (!net.pad) throw new Error(NOT_ON_MAINNET)
|
|
107
|
+
const bond = bondAddress(testnet)
|
|
108
|
+
if (!bond) throw new Error(`GangBond is not deployed on ${testnet ? 'the BSC testnet' : 'BSC mainnet'} in this CLI version: set ${testnet ? 'GANG_TESTNET_BOND' : 'GANG_BOND'} to its address`)
|
|
109
|
+
const client = createPublicClient({ chain: net.chain, transport: http(net.rpc) })
|
|
110
|
+
return { ...net, bond, client, read: (address, abi, functionName, args = []) => client.readContract({ address, abi, functionName, args }) }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** --from, else the signed-in Agentic Wallet (as gang v3 / gang fair). */
|
|
114
|
+
const walletFor = (ctx, from) => {
|
|
115
|
+
if (from) return from
|
|
116
|
+
try { return bawAddress(ctx.chainId) } catch { return bawAddress('56') }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const revertOf = (e) => revertIn(e, [BOND_ABI, MERGED_ERRORS_ABI], { ...MERGED_HINTS, ...HINTS })
|
|
120
|
+
|
|
121
|
+
async function simulate(ctx, from, functionName, args, value = 0n) {
|
|
122
|
+
try {
|
|
123
|
+
const { result } = await ctx.client.simulateContract({ account: from, address: ctx.bond, abi: BOND_ABI, functionName, args, value })
|
|
124
|
+
return { result }
|
|
125
|
+
} catch (e) {
|
|
126
|
+
const r = revertOf(e)
|
|
127
|
+
return { failed: r ? { reverted: true, ...r } : { reverted: false, error: e.shortMessage || e.message.split('\n')[0] } }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The token as the pad has it: kind, creator, quote, pair and whether it trades on PancakeSwap yet. null when not the pad's. */
|
|
132
|
+
async function marketOf(ctx, token) {
|
|
133
|
+
const kind = Number(await ctx.read(ctx.pad, PAD_ABI, 'kindOf', [token]))
|
|
134
|
+
if (kind === 1) {
|
|
135
|
+
const [creator, quote, , , , , graduated, , , , pair] = await ctx.read(ctx.pad, PAD_ABI, 'curves', [token])
|
|
136
|
+
return { kind: 'v3', creator, quote, pair, live: graduated }
|
|
137
|
+
}
|
|
138
|
+
if (kind === 2) {
|
|
139
|
+
const s = await ctx.read(ctx.pad, PAD_ABI, 'sales', [token])
|
|
140
|
+
return { kind: 'fair', creator: s[0], quote: s[4], pair: s[11], live: s[6] }
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const symbolOf = async (ctx, token) => { try { return await ctx.read(token, ERC20_ABI, 'symbol') } catch { return null } }
|
|
146
|
+
const pctOfBps = (bps) => Number(bps) / 100
|
|
147
|
+
const tokens = (x) => formatUnits(x, 18)
|
|
148
|
+
const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase()
|
|
149
|
+
const ceilDiv = (a, b) => (a + b - 1n) / b
|
|
150
|
+
|
|
151
|
+
/** Display tier of a coverage (bps): ≥ 100% full cover badge, 50–100% bond badge, below 50% no badge. */
|
|
152
|
+
const tierOf = (bps) => (bps >= BPS ? 'full' : bps >= 5_000n ? 'bond' : 'none')
|
|
153
|
+
const TIER_NOTE = { full: '押金全覆盖: dumping X now loses money (bond ≥ what the sale fetches)', bond: '押金: bond covers 50–100% of what dumping X fetches', none: 'below 50% coverage: no badge on gang.fun (bond and coverage still shown)' }
|
|
154
|
+
|
|
155
|
+
async function nowOf(ctx) {
|
|
156
|
+
return Number((await ctx.client.getBlock()).timestamp)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The bond and everything around it, formatted; raw fields kept under `_raw` for the commands. */
|
|
160
|
+
async function bondView(ctx, token, m) {
|
|
161
|
+
const b = await ctx.read(ctx.bond, BOND_ABI, 'bondOf', [token])
|
|
162
|
+
const state = STATES[Number(b.state)] ?? String(b.state)
|
|
163
|
+
const quote = b.quote === zeroAddress ? m.quote : b.quote
|
|
164
|
+
const [dec, sym, now] = await Promise.all([decimalsOf(ctx, quote), symbolOf(ctx, quote), nowOf(ctx)])
|
|
165
|
+
const q = (x) => formatUnits(x, dec)
|
|
166
|
+
const creator = b.creator === zeroAddress ? m.creator : b.creator
|
|
167
|
+
const [bal, history] = await Promise.all([
|
|
168
|
+
ctx.read(token, ERC20_ABI, 'balanceOf', [creator]),
|
|
169
|
+
ctx.read(ctx.bond, BOND_ABI, 'historyOf', [creator]),
|
|
170
|
+
])
|
|
171
|
+
const v = {
|
|
172
|
+
token, kind: m.kind, creator, state, round: Number(b.round), quote: sym || quote,
|
|
173
|
+
creatorHistory: { posted: Number(history[0]), honored: Number(history[1]), slashed: Number(history[2]) },
|
|
174
|
+
}
|
|
175
|
+
const pending = state === 'active' && b.deadline === 0n
|
|
176
|
+
const raw = { bond: b, bal, quote, dec, now, pending, live: m.live }
|
|
177
|
+
if (state === 'none') return { ...v, _raw: raw }
|
|
178
|
+
const days = (sec) => Math.round((Number(sec) / DAY) * 10) / 10
|
|
179
|
+
Object.assign(v, {
|
|
180
|
+
bond: q(b.amount), holdTokens: tokens(b.holdAmount), holdPctOfSupply: Number((b.holdAmount * 1_000_000n) / TOTAL_SUPPLY) / 10_000,
|
|
181
|
+
creatorBalance: tokens(bal), headroomTokens: bal >= b.holdAmount ? tokens(bal - b.holdAmount) : `-${tokens(b.holdAmount - bal)}`,
|
|
182
|
+
postedAt: new Date(Number(b.postedAt) * 1000).toISOString(), durationDays: days(b.duration),
|
|
183
|
+
deadline: b.deadline === 0n ? null : new Date(Number(b.deadline) * 1000).toISOString(),
|
|
184
|
+
})
|
|
185
|
+
if (state === 'active') {
|
|
186
|
+
const violated = await ctx.read(ctx.bond, BOND_ABI, 'isViolated', [token])
|
|
187
|
+
const violatedNote = violated && { note: `the creator holds less than X: anyone can report it (gang bond report --token ${token}) and take 1% of the bond` }
|
|
188
|
+
if (pending) {
|
|
189
|
+
// posted before the pair: the promise binds now, the clock starts when the token reaches its pair
|
|
190
|
+
const refundAt = Number(b.postedAt) + MAX_WAIT
|
|
191
|
+
Object.assign(v, {
|
|
192
|
+
phase: 'pending', violated, live: m.live,
|
|
193
|
+
clock: `starts when the token ${m.kind === 'v3' ? 'graduates' : 'launches'}: deadline = that moment + ${days(b.duration)} days`,
|
|
194
|
+
...(m.live
|
|
195
|
+
? { note: `the token is on its pair: anyone can start the clock (gang bond start --token ${token}); any other bond write starts it too` }
|
|
196
|
+
: { refundRequestFrom: new Date(refundAt * 1000).toISOString(), coverageNote: 'no coverage before the pair: shown once it trades on PancakeSwap' }),
|
|
197
|
+
...(b.refundAt !== 0n && !m.live && {
|
|
198
|
+
refundRequested: true, refundRunsAt: new Date(Number(b.refundAt) * 1000).toISOString(),
|
|
199
|
+
refundNote: 'the creator asked for the bond back: it can be withdrawn from refundRunsAt if the token still has no pair; the promise binds until then, and graduation / launch cancels the request',
|
|
200
|
+
}),
|
|
201
|
+
...violatedNote,
|
|
202
|
+
...(!violated && !m.live && raw.now >= refundAt && b.refundAt === 0n && { note: `no pair within 30 days of the post and X is held: the creator can ask for the bond back (gang bond refund --token ${token}); it runs 3 days later` }),
|
|
203
|
+
})
|
|
204
|
+
return { ...v, _raw: raw }
|
|
205
|
+
}
|
|
206
|
+
const [dump, cov] = await Promise.all([
|
|
207
|
+
ctx.read(ctx.bond, BOND_ABI, 'dumpValue', [token]),
|
|
208
|
+
ctx.read(ctx.bond, BOND_ABI, 'coverageBps', [token]),
|
|
209
|
+
])
|
|
210
|
+
const left = Number(b.deadline) - now
|
|
211
|
+
Object.assign(v, {
|
|
212
|
+
phase: 'running', violated, dumpValue: q(dump), coveragePct: cov === MAX_UINT ? null : pctOfBps(cov), tier: tierOf(cov), tierNote: TIER_NOTE[tierOf(cov)],
|
|
213
|
+
toFullCover: dump > b.amount ? q(dump - b.amount) : '0',
|
|
214
|
+
...(left > 0 ? { secondsLeft: left, daysLeft: Math.round((left / DAY) * 10) / 10 } : { pastDeadline: true }),
|
|
215
|
+
...violatedNote,
|
|
216
|
+
...(!violated && left <= 0 && { note: `past the deadline and X is held: anyone can settle it (gang bond settle --token ${token}); the bond goes back to the creator. Until then it can still be reported if the balance drops below X` }),
|
|
217
|
+
})
|
|
218
|
+
Object.assign(raw, { dump, cov })
|
|
219
|
+
}
|
|
220
|
+
if (state === 'slashed') {
|
|
221
|
+
const [ready, quoteIn, tokensOut] = await ctx.read(ctx.bond, BOND_ABI, 'previewBurn', [token])
|
|
222
|
+
Object.assign(v, {
|
|
223
|
+
burnLeft: q(b.burnLeft), burnDone: b.burnLeft === 0n,
|
|
224
|
+
...(b.burnLeft > 0n && !m.live && {
|
|
225
|
+
nextChunk: ready ? { quoteToDead: q(quoteIn) } : null,
|
|
226
|
+
nextChunkNote: ready
|
|
227
|
+
? 'slashed before the pair and still no pair 30 days after the post: gang bond burn --token … sends the rest to 0xdead as quote'
|
|
228
|
+
: `slashed before the pair: the buy-back starts once the token ${m.kind === 'v3' ? 'graduates' : 'launches'}; if it has not by ${new Date((Number(b.postedAt) + MAX_WAIT) * 1000).toISOString()}, the rest goes to 0xdead as quote`,
|
|
229
|
+
}),
|
|
230
|
+
...(b.burnLeft > 0n && m.live && { nextChunk: ready ? { quoteIn: q(quoteIn), tokensOut: tokens(tokensOut) } : null, nextChunkNote: ready ? 'gang bond burn --token … buys this chunk now' : 'not ready: the first call after a slash records a price observation, then the buy-back waits 10 minutes and skips while the price is more than 2% above its average (after 20 minutes without a call it starts over)' }),
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
return { ...v, _raw: raw }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** --hold: a token amount, or N% of the total supply (1,000,000,000). */
|
|
237
|
+
function parseHold(fail, raw) {
|
|
238
|
+
const s = String(raw ?? '').trim()
|
|
239
|
+
if (s.endsWith('%')) {
|
|
240
|
+
const n = Number(s.slice(0, -1))
|
|
241
|
+
if (!(n > 0 && n <= 100)) fail('--hold as a percentage must be above 0 and at most 100 (of the total supply)', { got: raw ?? null })
|
|
242
|
+
return (TOTAL_SUPPLY * BigInt(Math.round(n * 10_000))) / 1_000_000n
|
|
243
|
+
}
|
|
244
|
+
return parseUnits(parseAmount(fail, 'hold', raw), 18)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const tokenArg = (opt, fail) => {
|
|
248
|
+
const v = opt('token')
|
|
249
|
+
if (!isAddress(v ?? '')) fail('--token must be a GangLaunchpad token address (0x…)', { got: v ?? null })
|
|
250
|
+
return v
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The pay side of post / topUp: BNB for a WBNB quote (value; --use-wbnb pays WBNB), otherwise approve to GangBond first. */
|
|
254
|
+
async function payment(ctx, me, quote, amount, sym, useWbnb, rawOnly, summarize) {
|
|
255
|
+
if (same(quote, ctx.wbnb) && !useWbnb) {
|
|
256
|
+
const native = await ctx.client.getBalance({ address: me })
|
|
257
|
+
if (native < amount + GAS_RESERVE) {
|
|
258
|
+
return { pre: { success: false, error: 'not enough BNB for this bond (native BNB must also cover gas)', haveBnb: formatUnits(native, 18), needBnb: formatUnits(amount, 18), reserveForGas: formatUnits(GAS_RESERVE, 18), hint: 'add --use-wbnb to pay from WBNB instead' } }
|
|
259
|
+
}
|
|
260
|
+
return { value: amount }
|
|
261
|
+
}
|
|
262
|
+
const pre = await fund(ctx, { me, token: quote, need: amount, spender: ctx.bond, sym, approveStep: 'approve-quote' }, rawOnly, summarize)
|
|
263
|
+
return pre ? { pre } : { value: 0n }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const CLI_ONLY = 'the CLI refuses sells that would break the bond (gang v3 sell / gang fair sell), but a sell on the PancakeSwap site or another wallet is not stopped: it breaks the bond and anyone can report it'
|
|
267
|
+
|
|
268
|
+
// notes and follows are for trades and creates (gang.fun matches them to those txs); a bond tx carries none
|
|
269
|
+
const NOTE_FLAGS = ['note', 'conf', 'reveal-after', 'follow']
|
|
270
|
+
|
|
271
|
+
const SUBS = ['post', 'topup', 'raise-hold', 'extend', 'status', 'start', 'report', 'settle', 'refund', 'burn', 'tx']
|
|
272
|
+
const USAGE = 'usage: gang bond <post|topup|raise-hold|extend|status|start|report|settle|refund|burn|tx> --token <addr> [--testnet] — see gang help'
|
|
273
|
+
|
|
274
|
+
/** Entry for `gang bond <sub>`. io: { opt, flag, out, fail, summarize } from index.js. */
|
|
275
|
+
export async function runBond(sub, { opt, flag, out, fail, summarize }) {
|
|
276
|
+
if (!SUBS.includes(sub)) return fail(USAGE, { got: sub ?? null })
|
|
277
|
+
const noteFlag = NOTE_FLAGS.find((f) => flag(f))
|
|
278
|
+
if (noteFlag) return fail(`--${noteFlag}: gang bond takes no notes or follows (they go on trades and creates); leave it out`)
|
|
279
|
+
const testnet = flag('testnet')
|
|
280
|
+
const net = testnet ? ' --testnet' : ''
|
|
281
|
+
const rawOnly = flag('calldata')
|
|
282
|
+
let ctx
|
|
283
|
+
try { ctx = bondCtx(testnet) } catch (e) { return fail(e.message) }
|
|
284
|
+
const chainId = Number(ctx.chainId)
|
|
285
|
+
|
|
286
|
+
if (sub === 'tx') {
|
|
287
|
+
const hash = opt('hash')
|
|
288
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(hash ?? '')) fail('--hash must be a tx hash')
|
|
289
|
+
const r = await ctx.client.waitForTransactionReceipt({ hash, timeout: 120_000 })
|
|
290
|
+
const events = []
|
|
291
|
+
for (const log of r.logs) {
|
|
292
|
+
if (!same(log.address, ctx.bond)) continue
|
|
293
|
+
try {
|
|
294
|
+
const ev = decodeEventLog({ abi: BOND_ABI, data: log.data, topics: log.topics })
|
|
295
|
+
events.push({ event: ev.eventName, ...Object.fromEntries(Object.entries(ev.args).map(([k, v]) => [k, typeof v === 'bigint' ? v.toString() : v])) })
|
|
296
|
+
} catch { /* not a bond event */ }
|
|
297
|
+
}
|
|
298
|
+
return out({ success: true, chainId, hash, status: r.status, block: Number(r.blockNumber), events, explorer: `${ctx.explorer}/tx/${hash}` })
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const token = tokenArg(opt, fail)
|
|
302
|
+
const m = await marketOf(ctx, token)
|
|
303
|
+
if (!m) return fail('not a GangLaunchpad token (V3 or 打新) on this network: bonds cover only those', { token, chainId })
|
|
304
|
+
const view = await bondView(ctx, token, m)
|
|
305
|
+
const { _raw: raw, ...pub } = view
|
|
306
|
+
const b = raw.bond
|
|
307
|
+
const qf = (x) => formatUnits(x, raw.dec)
|
|
308
|
+
let wallet = null
|
|
309
|
+
const me = () => (wallet ??= walletFor(ctx, opt('from')))
|
|
310
|
+
const send = (functionName, args, extra, value = 0n) =>
|
|
311
|
+
step(ctx, { from: me(), to: ctx.bond, data: encodeFunctionData({ abi: BOND_ABI, functionName, args }), value }, { chainId, token, bondContract: ctx.bond, ...extra }, rawOnly, summarize)
|
|
312
|
+
|
|
313
|
+
if (sub === 'status') {
|
|
314
|
+
// no bond yet on a token before its pair: say a bond can be posted now (the view's own notes win otherwise)
|
|
315
|
+
const before = view.state === 'none' && !m.live
|
|
316
|
+
&& { note: `the token is not on PancakeSwap yet (${m.kind === 'v3' ? 'not graduated' : 'not launched'}): its creator can post a bond now; the --days clock starts when it ${m.kind === 'v3' ? 'graduates' : 'launches'}` }
|
|
317
|
+
return out({ success: true, chainId, bondContract: ctx.bond, ...pub, ...before })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (sub === 'report') {
|
|
321
|
+
if (view.state !== 'active') return fail(HINTS.NotActive, { ...pub })
|
|
322
|
+
if (!view.violated) return fail(HINTS.NotViolated, { creatorBalance: view.creatorBalance, holdTokens: view.holdTokens })
|
|
323
|
+
const sim = await simulate(ctx, me(), 'report', [token])
|
|
324
|
+
if (sim.failed) return out({ success: false, step: 'report', ...sim.failed })
|
|
325
|
+
const self = same(me(), b.creator)
|
|
326
|
+
const rewardBps = await ctx.read(ctx.bond, BOND_ABI, 'REPORTER_BPS')
|
|
327
|
+
const reward = self ? 0n : (b.amount * rewardBps) / BPS
|
|
328
|
+
return out(send('report', [token], {
|
|
329
|
+
step: 'report', creator: b.creator, creatorBalance: view.creatorBalance, holdTokens: view.holdTokens, bond: view.bond, quote: view.quote,
|
|
330
|
+
reward: qf(reward), rewardPct: self ? 0 : pctOfBps(rewardBps), burnAfter: qf(b.amount - reward),
|
|
331
|
+
note: self ? 'the creator reporting itself gets no reward: the whole bond goes to buy-back' : `you get ${pctOfBps(rewardBps)}% of the bond; the rest buys the token back from its pair in chunks and burns it (gang bond burn)`,
|
|
332
|
+
}))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (sub === 'start') {
|
|
336
|
+
if (view.state !== 'active') return fail(HINTS.NotActive, { ...pub })
|
|
337
|
+
if (!raw.pending) return fail('the clock is already running', { deadline: view.deadline })
|
|
338
|
+
if (!m.live) return fail(`the token has not ${m.kind === 'v3' ? 'graduated' : 'launched'} yet: the clock starts when it reaches its pair`, { refundRequestFrom: view.refundRequestFrom })
|
|
339
|
+
const sim = await simulate(ctx, me(), 'start', [token])
|
|
340
|
+
if (sim.failed) return out({ success: false, step: 'start', ...sim.failed })
|
|
341
|
+
return out(send('start', [token], {
|
|
342
|
+
step: 'start', durationDays: view.durationDays, deadlineAbout: new Date((raw.now + Number(b.duration)) * 1000).toISOString(),
|
|
343
|
+
note: 'anyone may start it; the deadline is the block this lands in + the duration',
|
|
344
|
+
}))
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (sub === 'refund') {
|
|
348
|
+
// creator only, two steps: request (30 days without a pair since the post), then refund after a 3-day notice
|
|
349
|
+
if (view.state !== 'active') return fail(HINTS.NotActive, { ...pub })
|
|
350
|
+
if (!same(me(), b.creator)) return fail('only the creator can ask for its bond back', { wallet: me(), creator: b.creator })
|
|
351
|
+
if (!raw.pending || m.live) return fail(HINTS.AlreadyLive, { deadline: view.deadline })
|
|
352
|
+
if (view.violated) return fail(HINTS.Violated, { creatorBalance: view.creatorBalance, holdTokens: view.holdTokens })
|
|
353
|
+
if (b.refundAt === 0n) {
|
|
354
|
+
const requestAt = Number(b.postedAt) + MAX_WAIT
|
|
355
|
+
if (raw.now < requestAt) return fail('a refund can be asked for only 30 days after the post without a pair', { refundRequestFrom: view.refundRequestFrom, secondsLeft: requestAt - raw.now })
|
|
356
|
+
const sim = await simulate(ctx, me(), 'requestRefund', [token])
|
|
357
|
+
if (sim.failed) return out({ success: false, step: 'request-refund', ...sim.failed })
|
|
358
|
+
return out(send('requestRefund', [token], {
|
|
359
|
+
step: 'request-refund', bond: view.bond, quote: view.quote, refundRunsAbout: new Date((raw.now + REFUND_NOTICE) * 1000).toISOString(),
|
|
360
|
+
note: 'step 1 of 2: starts a 3-day notice (shown on gang.fun). Keep holding X: a breach can still be reported, and if the token graduates / launches meanwhile the request is void and the clock starts. Then run gang bond refund again',
|
|
361
|
+
}))
|
|
362
|
+
}
|
|
363
|
+
if (raw.now < Number(b.refundAt)) return fail('the refund notice is not over yet', { refundRunsAt: view.refundRunsAt, secondsLeft: Number(b.refundAt) - raw.now })
|
|
364
|
+
const sim = await simulate(ctx, me(), 'refund', [token])
|
|
365
|
+
if (sim.failed) return out({ success: false, step: 'refund', ...sim.failed })
|
|
366
|
+
return out(send('refund', [token], { step: 'refund', refund: view.bond, quote: view.quote, note: 'step 2 of 2: no pair after the notice, X held: the bond comes back to you (not counted as honored)' }))
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (sub === 'settle') {
|
|
370
|
+
if (view.state !== 'active') return fail(HINTS.NotActive, { ...pub })
|
|
371
|
+
if (raw.pending) {
|
|
372
|
+
return fail(m.live ? 'the clock has not started: settle comes duration days after it' : 'the token is not on its pair yet: settle comes after graduation / launch + the duration', {
|
|
373
|
+
durationDays: view.durationDays, hint: m.live ? `gang bond start --token ${token}${net}` : `if there is still no pair 30 days after the post, the creator can ask for the bond back: gang bond refund --token ${token}${net} (it runs 3 days later)`,
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
if (raw.now < Number(b.deadline)) return fail(HINTS.NotDue, { deadline: view.deadline, secondsLeft: Number(b.deadline) - raw.now })
|
|
377
|
+
if (view.violated) return fail(HINTS.Violated, { creatorBalance: view.creatorBalance, holdTokens: view.holdTokens, hint: `gang bond report --token ${token}${net}` })
|
|
378
|
+
const sim = await simulate(ctx, me(), 'settle', [token])
|
|
379
|
+
if (sim.failed) return out({ success: false, step: 'settle', ...sim.failed })
|
|
380
|
+
return out(send('settle', [token], { step: 'settle', creator: b.creator, refund: view.bond, quote: view.quote, note: 'the bond goes back to the creator, whoever sends this' }))
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (sub === 'burn') {
|
|
384
|
+
if (view.state !== 'slashed' || b.burnLeft === 0n) return fail(HINTS.NothingToBurn, { state: view.state, ...(view.burnLeft && { burnLeft: view.burnLeft }) })
|
|
385
|
+
const slippage = parseNum(fail, 'slippage', opt('slippage', '1'), 0, 50)
|
|
386
|
+
const [ready, quoteIn, tokensOut] = await ctx.read(ctx.bond, BOND_ABI, 'previewBurn', [token])
|
|
387
|
+
// not ready: the call only records / waits. If a chunk has become buyable by the time it lands, the max minimum
|
|
388
|
+
// makes it revert (Slippage) instead of buying unchecked: re-run for a priced preview
|
|
389
|
+
const toDead = ready && !m.live
|
|
390
|
+
const minOut = toDead ? 0n : ready ? (tokensOut * BigInt(Math.round((100 - slippage) * 100))) / BPS : MAX_UINT
|
|
391
|
+
const sim = await simulate(ctx, me(), 'burnChunk', [token, minOut])
|
|
392
|
+
if (sim.failed) return out({ success: false, step: 'burn', ...sim.failed })
|
|
393
|
+
return out(send('burnChunk', [token, minOut], {
|
|
394
|
+
step: 'burn', burnLeft: view.burnLeft, quote: view.quote,
|
|
395
|
+
...(toDead ? { quoteToDead: qf(quoteIn), note: 'slashed before the pair and still no pair 30 days after the post: the rest goes to 0xdead as quote' }
|
|
396
|
+
: ready ? { chunkQuoteIn: qf(quoteIn), expectBurnTokens: tokens(tokensOut), minBurnTokens: tokens(minOut), slippagePct: slippage }
|
|
397
|
+
: { note: 'no chunk this time: the call records the price observation (first call after a slash) or waits for the 10-minute average / a price no more than 2% above it. It is cheap; the platform keeper also calls it every 10 minutes. If a chunk becomes buyable before it lands, it reverts (Slippage) rather than buy without a minimum: re-run then' }),
|
|
398
|
+
}))
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// creator commands (any time after create: on the curve / during the sale too)
|
|
402
|
+
if (!same(me(), m.creator)) return fail(HINTS.NotCreator, { wallet: me(), creator: m.creator })
|
|
403
|
+
|
|
404
|
+
if (sub === 'post') {
|
|
405
|
+
if (view.state === 'active' || (view.state === 'slashed' && b.burnLeft > 0n)) return fail(HINTS.BondLive, { ...pub })
|
|
406
|
+
if (!flag('hold')) fail('--hold <tokens or N% of supply> required: the creator\'s balance must stay at least this until the deadline')
|
|
407
|
+
if (flag('amount') === flag('cover')) fail('pass exactly one of --amount <quote> or --cover <coverage %> (e.g. --cover 100 for full cover)')
|
|
408
|
+
const hold = parseHold(fail, opt('hold'))
|
|
409
|
+
const days = parseIntIn(fail, 'days', opt('days'), 7, 90)
|
|
410
|
+
if (hold === 0n) fail('--hold rounds to 0 tokens')
|
|
411
|
+
if (hold > raw.bal) {
|
|
412
|
+
fail('--hold is above the creator\'s balance now', {
|
|
413
|
+
hold: tokens(hold), balance: tokens(raw.bal), ...(!m.live && m.kind === 'v3' && raw.bal === 0n && { hint: `buy on the curve first (gang v3 buy --token ${token}${net}), then bond what you hold` }),
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
if (flag('cover') && !m.live) fail('--cover needs the PancakeSwap pair to price X: before graduation / launch give --amount <quote>')
|
|
417
|
+
const dump = m.live ? await ctx.read(ctx.bond, BOND_ABI, 'quoteDump', [token, hold]) : null
|
|
418
|
+
const amount = flag('amount') ? parseUnits(parseAmount(fail, 'amount', opt('amount')), raw.dec)
|
|
419
|
+
: ceilDiv(dump * BigInt(Math.round(parseNum(fail, 'cover', opt('cover'), 1, 1000) * 100)), BPS)
|
|
420
|
+
if (amount === 0n) fail('the bond rounds to 0')
|
|
421
|
+
const duration = BigInt(days * DAY)
|
|
422
|
+
const pay = await payment(ctx, me(), raw.quote, amount, view.quote, flag('use-wbnb'), rawOnly, summarize)
|
|
423
|
+
if (pay.pre) return out(pay.pre)
|
|
424
|
+
const sim = await simulate(ctx, me(), 'post', [token, amount, hold, duration], pay.value)
|
|
425
|
+
if (sim.failed) return out({ success: false, step: 'post', ...sim.failed })
|
|
426
|
+
const cov = !dump ? null : (amount * BPS) / dump
|
|
427
|
+
const until = m.live ? 'the deadline' : `${days} days after the token ${m.kind === 'v3' ? 'graduates' : 'launches'}`
|
|
428
|
+
return out(send('post', [token, amount, hold, duration], {
|
|
429
|
+
step: 'post', bond: qf(amount), quote: view.quote, payWith: pay.value ? 'BNB' : view.quote,
|
|
430
|
+
holdTokens: tokens(hold), holdPctOfSupply: Number((hold * 1_000_000n) / TOTAL_SUPPLY) / 10_000, creatorBalance: tokens(raw.bal), days,
|
|
431
|
+
...(m.live
|
|
432
|
+
? {
|
|
433
|
+
deadlineAbout: new Date((raw.now + days * DAY) * 1000).toISOString(),
|
|
434
|
+
dumpValue: qf(dump), coveragePct: cov == null ? null : pctOfBps(cov), tier: cov == null ? 'full' : tierOf(cov),
|
|
435
|
+
toFullCover: dump > amount ? qf(dump - amount) : '0',
|
|
436
|
+
}
|
|
437
|
+
: {
|
|
438
|
+
clock: `starts when the token ${m.kind === 'v3' ? 'graduates' : 'launches'}: deadline = that moment + ${days} days`,
|
|
439
|
+
refundRequestFrom: new Date((raw.now + MAX_WAIT) * 1000).toISOString(),
|
|
440
|
+
coverageNote: 'no coverage before the pair: gang.fun shows the bond amount and X until it trades on PancakeSwap',
|
|
441
|
+
}),
|
|
442
|
+
warning: `from now until settled after ${until}, keep at least ${tokens(hold)} tokens in ${m.creator}: below that anyone can report it, the reporter takes 1% and the rest of the bond buys back and burns the token.`
|
|
443
|
+
+ (m.live ? ' There is no early withdrawal.' : ' There is no early withdrawal; only if the token has no pair 30 days after this post can you ask for it back, and it comes back 3 days after asking if the token still has no pair and X is still held.')
|
|
444
|
+
+ ` ${CLI_ONLY}`,
|
|
445
|
+
}, pay.value))
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (view.state !== 'active') return fail(HINTS.NotActive, { ...pub })
|
|
449
|
+
|
|
450
|
+
if (sub === 'topup') {
|
|
451
|
+
if (flag('amount') === flag('cover')) fail('pass exactly one of --amount <quote> or --cover <target coverage %>')
|
|
452
|
+
let amount
|
|
453
|
+
if (flag('amount')) amount = parseUnits(parseAmount(fail, 'amount', opt('amount')), raw.dec)
|
|
454
|
+
else {
|
|
455
|
+
if (raw.pending) fail('--cover needs the clock running on the PancakeSwap pair: give --amount <quote>', { phase: 'pending' })
|
|
456
|
+
const target = ceilDiv(raw.dump * BigInt(Math.round(parseNum(fail, 'cover', opt('cover'), 1, 1000) * 100)), BPS)
|
|
457
|
+
if (target <= b.amount) return fail(`the bond already covers ${view.coveragePct}%: nothing to add for --cover ${opt('cover')}`, { bond: view.bond, dumpValue: view.dumpValue })
|
|
458
|
+
amount = target - b.amount
|
|
459
|
+
}
|
|
460
|
+
const pay = await payment(ctx, me(), raw.quote, amount, view.quote, flag('use-wbnb'), rawOnly, summarize)
|
|
461
|
+
if (pay.pre) return out(pay.pre)
|
|
462
|
+
const sim = await simulate(ctx, me(), 'topUp', [token, amount], pay.value)
|
|
463
|
+
if (sim.failed) return out({ success: false, step: 'topup', ...sim.failed })
|
|
464
|
+
const total = b.amount + amount
|
|
465
|
+
const cov = !raw.dump ? null : (total * BPS) / raw.dump
|
|
466
|
+
return out(send('topUp', [token, amount], {
|
|
467
|
+
step: 'topup', add: qf(amount), bondAfter: qf(total), quote: view.quote, payWith: pay.value ? 'BNB' : view.quote,
|
|
468
|
+
dumpValue: view.dumpValue, coveragePct: cov == null ? null : pctOfBps(cov), tier: cov == null ? 'full' : tierOf(cov), deadline: view.deadline,
|
|
469
|
+
note: 'a bond can only grow: nothing comes back before settle',
|
|
470
|
+
}, pay.value))
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (sub === 'raise-hold') {
|
|
474
|
+
if (!flag('hold')) fail('--hold <tokens or N% of supply> required')
|
|
475
|
+
const hold = parseHold(fail, opt('hold'))
|
|
476
|
+
if (hold < b.holdAmount) fail('--hold is below the current X: X can only go up', { holdTokens: view.holdTokens })
|
|
477
|
+
if (hold > raw.bal) fail('--hold is above the creator\'s balance now', { balance: view.creatorBalance })
|
|
478
|
+
const sim = await simulate(ctx, me(), 'raiseHold', [token, hold])
|
|
479
|
+
if (sim.failed) return out({ success: false, step: 'raise-hold', ...sim.failed })
|
|
480
|
+
const dump = m.live ? await ctx.read(ctx.bond, BOND_ABI, 'quoteDump', [token, hold]) : null
|
|
481
|
+
const cov = !dump ? null : (b.amount * BPS) / dump
|
|
482
|
+
return out(send('raiseHold', [token, hold], {
|
|
483
|
+
step: 'raise-hold', holdBefore: view.holdTokens, holdAfter: tokens(hold), creatorBalance: view.creatorBalance,
|
|
484
|
+
...(dump != null && { dumpValueAfter: qf(dump), coveragePctAfter: cov == null ? null : pctOfBps(cov) }), deadline: view.deadline,
|
|
485
|
+
note: 'X can only go up; a higher X means dumping it fetches more, so the same bond covers less',
|
|
486
|
+
}))
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (sub === 'extend') {
|
|
490
|
+
const days = parseIntIn(fail, 'days', opt('days'), 1, 90)
|
|
491
|
+
const by = BigInt(days * DAY)
|
|
492
|
+
if (raw.pending) {
|
|
493
|
+
// before the pair: the duration grows (90 days in all at most)
|
|
494
|
+
if (Number(b.duration) + days * DAY > 90 * DAY) fail('the commitment would be more than 90 days in all', { durationDays: view.durationDays, maxDays: Math.floor((90 * DAY - Number(b.duration)) / DAY) })
|
|
495
|
+
} else if (Number(b.deadline) + days * DAY > raw.now + 90 * DAY) {
|
|
496
|
+
fail('the new deadline would be more than 90 days from now', { deadline: view.deadline, maxDays: Math.floor((raw.now + 90 * DAY - Number(b.deadline)) / DAY) })
|
|
497
|
+
}
|
|
498
|
+
const sim = await simulate(ctx, me(), 'extend', [token, by])
|
|
499
|
+
if (sim.failed) return out({ success: false, step: 'extend', ...sim.failed })
|
|
500
|
+
return out(send('extend', [token, by], {
|
|
501
|
+
step: 'extend', days,
|
|
502
|
+
...(raw.pending
|
|
503
|
+
? { durationDaysBefore: view.durationDays, durationDaysAfter: view.durationDays + days, clock: 'starts at graduation / launch' }
|
|
504
|
+
: { deadlineBefore: view.deadline, deadlineAfter: new Date((Number(b.deadline) + days * DAY) * 1000).toISOString(), coveragePct: view.coveragePct }),
|
|
505
|
+
}))
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return fail(USAGE, { got: sub })
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Sell protection for the creator's own sells: gang v3 sell (curve or pool), gang fair sell (pool), gang fair withdraw
|
|
513
|
+
* (shares back to the pad while minting). `p`: { token, me, amount,
|
|
514
|
+
* breakBond, settleFirst, testnet }. Returns null when there is nothing to guard (no GangBond on this network, no active
|
|
515
|
+
* bond, not the creator), else one of:
|
|
516
|
+
* - { refuse: {…} } — the sell would leave less than X before the deadline (no --break-bond), or after the deadline
|
|
517
|
+
* before settle (no --settle-first): print it as the failure;
|
|
518
|
+
* - { settleStep: {…} } — --settle-first after the deadline: send this settle first, then re-run the sell;
|
|
519
|
+
* - { info: {…} } — go on, with these fields added to the preview (headroom after the sell, or the --break-bond warning).
|
|
520
|
+
*/
|
|
521
|
+
export async function bondGuard(ctx, p, rawOnly, summarize) {
|
|
522
|
+
const addr = bondAddress(p.testnet)
|
|
523
|
+
if (!addr) return null
|
|
524
|
+
let b
|
|
525
|
+
try { b = await ctx.read(addr, BOND_ABI, 'bondOf', [p.token]) } catch (e) {
|
|
526
|
+
// never let an unread bond pass a sell that may break it
|
|
527
|
+
if (p.breakBond) return null
|
|
528
|
+
return { refuse: { error: 'cannot read this token\'s GangBond, so the sell is not checked against a bond', bondContract: addr, detail: e.shortMessage || e.message.split('\n')[0], hint: 're-run; --break-bond skips the check' } }
|
|
529
|
+
}
|
|
530
|
+
if (Number(b.state) !== 1 || !same(b.creator, p.me)) return null
|
|
531
|
+
let bal, block, dec
|
|
532
|
+
try {
|
|
533
|
+
;[bal, block, dec] = await Promise.all([ctx.read(p.token, ERC20_ABI, 'balanceOf', [p.me]), ctx.client.getBlock(), decimalsOf(ctx, b.quote)])
|
|
534
|
+
} catch (e) {
|
|
535
|
+
if (p.breakBond) return null
|
|
536
|
+
return { refuse: { error: 'RPC error while checking the sell against your bond', bondContract: addr, detail: e.shortMessage || e.message.split('\n')[0], hint: 're-run; set GANG_RPC_URL / GANG_TESTNET_RPC_URL if the RPC keeps failing' } }
|
|
537
|
+
}
|
|
538
|
+
const after = bal > p.amount ? bal - p.amount : 0n
|
|
539
|
+
// pending (deadline 0, posted before the pair): the promise binds, nothing is past due
|
|
540
|
+
const pastDeadline = b.deadline !== 0n && Number(block.timestamp) >= Number(b.deadline)
|
|
541
|
+
const base = {
|
|
542
|
+
bondContract: addr, holdTokens: tokens(b.holdAmount), balanceBefore: tokens(bal), balanceAfter: tokens(after),
|
|
543
|
+
bond: formatUnits(b.amount, dec),
|
|
544
|
+
...(b.deadline === 0n ? { clock: 'starts at graduation / launch' } : { deadline: new Date(Number(b.deadline) * 1000).toISOString() }),
|
|
545
|
+
}
|
|
546
|
+
if (after >= b.holdAmount) {
|
|
547
|
+
return { info: { bondCheck: { ...base, headroomAfter: tokens(after - b.holdAmount), ...(pastDeadline && { note: 'past the deadline: settle the bond (gang bond settle) to get it back; until then it can still be reported if the balance drops below X' }) } } }
|
|
548
|
+
}
|
|
549
|
+
const net = p.testnet ? ' --testnet' : ''
|
|
550
|
+
if (pastDeadline && bal >= b.holdAmount) {
|
|
551
|
+
if (!p.settleFirst) {
|
|
552
|
+
return { refuse: { error: `the bond is past its deadline but not settled: this sell leaves ${tokens(after)} tokens, below X (${tokens(b.holdAmount)}), and anyone could report it before you settle`, ...base, hint: `run gang bond settle --token ${p.token}${net} first, or add --settle-first to this sell` } }
|
|
553
|
+
}
|
|
554
|
+
const data = encodeFunctionData({ abi: BOND_ABI, functionName: 'settle', args: [p.token] })
|
|
555
|
+
return { settleStep: step(ctx, { from: p.me, to: addr, data }, { step: 'settle', chainId: Number(ctx.chainId), token: p.token, refund: base.bond, ...base, note: 'settle first (the bond comes back to you); after executing, re-run the same sell' }, rawOnly, summarize) }
|
|
556
|
+
}
|
|
557
|
+
const lost = formatUnits(b.amount, dec)
|
|
558
|
+
const consequence = `anyone can report it, the reporter takes 1% and the rest of the ${lost} bond buys back and burns the token`
|
|
559
|
+
const warning = bal < b.holdAmount
|
|
560
|
+
? `the bond is already broken: you hold ${tokens(bal)} tokens, below the bonded X (${tokens(b.holdAmount)}), so ${consequence}. Selling more does not change that; buying back to X before anyone reports does`
|
|
561
|
+
: `this sell leaves ${tokens(after)} tokens, below the bonded X (${tokens(b.holdAmount)}) before the bond is settled: ${consequence}. You lose the whole bond`
|
|
562
|
+
if (!p.breakBond) return { refuse: { error: warning, ...base, maxSellKeepingBond: tokens(bal > b.holdAmount ? bal - b.holdAmount : 0n), hint: 'sell at most maxSellKeepingBond, or add --break-bond once the user accepts losing the bond' } }
|
|
563
|
+
return { info: { bondCheck: { ...base, warning, brokeBond: true } } }
|
|
564
|
+
}
|