@w3lib/solana-batch-call 0.4.0 → 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/CHANGELOG.md +31 -0
- package/README.md +231 -3
- package/dist/approvals.d.ts +129 -0
- package/dist/approvals.d.ts.map +1 -0
- package/dist/approvals.js +221 -0
- package/dist/approvals.js.map +1 -0
- package/dist/fees.d.ts +89 -0
- package/dist/fees.d.ts.map +1 -0
- package/dist/fees.js +166 -0
- package/dist/fees.js.map +1 -0
- package/dist/layout.d.ts +2 -0
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +2 -2
- package/dist/layout.js.map +1 -1
- package/dist/lp.d.ts +8 -15
- package/dist/lp.d.ts.map +1 -1
- package/dist/lp.js +7 -31
- package/dist/lp.js.map +1 -1
- package/dist/names.d.ts +68 -0
- package/dist/names.d.ts.map +1 -0
- package/dist/names.js +257 -0
- package/dist/names.js.map +1 -0
- package/dist/pools.d.ts +9 -0
- package/dist/pools.d.ts.map +1 -0
- package/dist/pools.js +45 -0
- package/dist/pools.js.map +1 -0
- package/dist/portfolio.d.ts +64 -0
- package/dist/portfolio.d.ts.map +1 -0
- package/dist/portfolio.js +57 -0
- package/dist/portfolio.js.map +1 -0
- package/dist/price.d.ts +90 -0
- package/dist/price.d.ts.map +1 -0
- package/dist/price.js +287 -0
- package/dist/price.js.map +1 -0
- package/dist/risk.d.ts +84 -0
- package/dist/risk.d.ts.map +1 -0
- package/dist/risk.js +211 -0
- package/dist/risk.js.map +1 -0
- package/dist/rpc.d.ts +2 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +33 -9
- package/dist/rpc.js.map +1 -1
- package/dist/subpaths/approvals.d.ts +29 -0
- package/dist/subpaths/approvals.d.ts.map +1 -0
- package/dist/subpaths/approvals.js +30 -0
- package/dist/subpaths/approvals.js.map +1 -0
- package/dist/subpaths/fees.d.ts +23 -0
- package/dist/subpaths/fees.d.ts.map +1 -0
- package/dist/subpaths/fees.js +25 -0
- package/dist/subpaths/fees.js.map +1 -0
- package/dist/subpaths/names.d.ts +37 -0
- package/dist/subpaths/names.d.ts.map +1 -0
- package/dist/subpaths/names.js +27 -0
- package/dist/subpaths/names.js.map +1 -0
- package/dist/subpaths/price.d.ts +40 -0
- package/dist/subpaths/price.d.ts.map +1 -0
- package/dist/subpaths/price.js +43 -0
- package/dist/subpaths/price.js.map +1 -0
- package/dist/subpaths/risk.d.ts +28 -0
- package/dist/subpaths/risk.d.ts.map +1 -0
- package/dist/subpaths/risk.js +30 -0
- package/dist/subpaths/risk.js.map +1 -0
- package/dist/subpaths/swap.d.ts +22 -0
- package/dist/subpaths/swap.d.ts.map +1 -0
- package/dist/subpaths/swap.js +21 -0
- package/dist/subpaths/swap.js.map +1 -0
- package/dist/subpaths/write.d.ts +1 -1
- package/dist/subpaths/write.d.ts.map +1 -1
- package/dist/subpaths/write.js +1 -1
- package/dist/subpaths/write.js.map +1 -1
- package/dist/swap/amm.d.ts +20 -0
- package/dist/swap/amm.d.ts.map +1 -0
- package/dist/swap/amm.js +77 -0
- package/dist/swap/amm.js.map +1 -0
- package/dist/swap/clmmMath.d.ts +24 -0
- package/dist/swap/clmmMath.d.ts.map +1 -0
- package/dist/swap/clmmMath.js +82 -0
- package/dist/swap/clmmMath.js.map +1 -0
- package/dist/swap/common.d.ts +68 -0
- package/dist/swap/common.d.ts.map +1 -0
- package/dist/swap/common.js +65 -0
- package/dist/swap/common.js.map +1 -0
- package/dist/swap/cpmm.d.ts +28 -0
- package/dist/swap/cpmm.d.ts.map +1 -0
- package/dist/swap/cpmm.js +113 -0
- package/dist/swap/cpmm.js.map +1 -0
- package/dist/swap/index.d.ts +145 -0
- package/dist/swap/index.d.ts.map +1 -0
- package/dist/swap/index.js +349 -0
- package/dist/swap/index.js.map +1 -0
- package/dist/swap/orca.d.ts +50 -0
- package/dist/swap/orca.d.ts.map +1 -0
- package/dist/swap/orca.js +382 -0
- package/dist/swap/orca.js.map +1 -0
- package/dist/swap/raydiumClmm.d.ts +62 -0
- package/dist/swap/raydiumClmm.d.ts.map +1 -0
- package/dist/swap/raydiumClmm.js +447 -0
- package/dist/swap/raydiumClmm.js.map +1 -0
- package/dist/tradeCheck.d.ts +45 -0
- package/dist/tradeCheck.d.ts.map +1 -0
- package/dist/tradeCheck.js +131 -0
- package/dist/tradeCheck.js.map +1 -0
- package/dist/write/instructions.d.ts +7 -0
- package/dist/write/instructions.d.ts.map +1 -1
- package/dist/write/instructions.js +24 -1
- package/dist/write/instructions.js.map +1 -1
- package/dist/write/transaction.d.ts +23 -0
- package/dist/write/transaction.d.ts.map +1 -1
- package/dist/write/transaction.js +50 -0
- package/dist/write/transaction.js.map +1 -1
- package/package.json +46 -14
- package/src/approvals.ts +358 -0
- package/src/fees.ts +259 -0
- package/src/layout.ts +2 -2
- package/src/lp.ts +8 -44
- package/src/names.ts +329 -0
- package/src/pools.ts +43 -0
- package/src/portfolio.ts +124 -0
- package/src/price.ts +366 -0
- package/src/risk.ts +243 -0
- package/src/rpc.ts +36 -9
- package/src/subpaths/approvals.ts +47 -0
- package/src/subpaths/fees.ts +41 -0
- package/src/subpaths/names.ts +45 -0
- package/src/subpaths/price.ts +62 -0
- package/src/subpaths/risk.ts +36 -0
- package/src/subpaths/swap.ts +41 -0
- package/src/subpaths/write.ts +2 -0
- package/src/swap/amm.ts +88 -0
- package/src/swap/clmmMath.ts +96 -0
- package/src/swap/common.ts +113 -0
- package/src/swap/cpmm.ts +132 -0
- package/src/swap/index.ts +467 -0
- package/src/swap/orca.ts +397 -0
- package/src/swap/raydiumClmm.ts +464 -0
- package/src/tradeCheck.ts +166 -0
- package/src/write/instructions.ts +26 -1
- package/src/write/transaction.ts +62 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raydium CLMM 的兑换:swap_v2(按输入数量)。报价照搬程序的 swap_internal(instructions/swap.rs,2026-09 的版本):
|
|
3
|
+
* 按 tick 数组位图找下一个已初始化的 tick,逐个区间计算;包括 fee_on(手续费从输入 / 输出扣)、动态费率(按波动累计)、
|
|
4
|
+
* tick 上的限价单(先按 tick 价格成交)、两边的 Token-2022 转账手续费。
|
|
5
|
+
* 不支持需要位图扩展账户的池子(当前价格或兑换路径超出池子自带位图覆盖的范围,只会出现在 tick spacing < 15 的池子)
|
|
6
|
+
*/
|
|
7
|
+
import { MEMO_PROGRAM_ID, Writer, anchorIx, r, w } from '../liquidity/common.js'
|
|
8
|
+
import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../constants.js'
|
|
9
|
+
import type { AccountInfo } from '../layout.js'
|
|
10
|
+
import { RAYDIUM_CLMM_PROGRAM_ID, raydiumTickArray } from '../lp.js'
|
|
11
|
+
import { MAX_TICK, MIN_TICK, RAYDIUM_MAX_SQRT_PRICE, RAYDIUM_MIN_SQRT_PRICE, raydiumSqrtPriceAtTick, tickAtSqrtPrice } from '../lpMath.js'
|
|
12
|
+
import type { Instruction } from '../write/transaction.js'
|
|
13
|
+
import { FEE_RATE_DENOMINATOR, SwapMathError, computeSwapStep } from './clmmMath.js'
|
|
14
|
+
import { Bytes, NeedAccounts, UnsupportedPool, U64_MAX, mulDivCeil, need, swapMint, transferFee, type AccountGetter, type HopQuote, type SwapEnv, type SwapMint, type SwapPool } from './common.js'
|
|
15
|
+
|
|
16
|
+
const POOL_SIZE = 1544
|
|
17
|
+
const TICK_ARRAY_LEN = 10240
|
|
18
|
+
const TICK_ARRAY_SIZE = 60
|
|
19
|
+
const TICK_SIZE = 168
|
|
20
|
+
const BITMAP_SIZE = 512
|
|
21
|
+
const Q64 = 1n << 64n
|
|
22
|
+
/** 先读几个 tick 数组(当前的和兑换方向上已初始化的),不够时每次再多读 3 个,最多 MAX_TICK_ARRAYS 个 */
|
|
23
|
+
const INITIAL_TICK_ARRAYS = 2
|
|
24
|
+
const MAX_TICK_ARRAYS = 10
|
|
25
|
+
const MAX_FEE_RATE = 100_000n
|
|
26
|
+
const DYNAMIC_FEE_CONTROL_DENOMINATOR = 100_000n
|
|
27
|
+
const VOLATILITY_SCALE = 10_000n
|
|
28
|
+
const REDUCTION_FACTOR_DENOMINATOR = 10_000n
|
|
29
|
+
|
|
30
|
+
interface DynamicFee {
|
|
31
|
+
filterPeriod: bigint
|
|
32
|
+
decayPeriod: bigint
|
|
33
|
+
reductionFactor: bigint
|
|
34
|
+
control: bigint
|
|
35
|
+
maxVolatility: bigint
|
|
36
|
+
indexReference: number
|
|
37
|
+
volatilityReference: bigint
|
|
38
|
+
volatility: bigint
|
|
39
|
+
lastUpdate: bigint
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RaydiumClmmState {
|
|
43
|
+
address: string
|
|
44
|
+
config: string
|
|
45
|
+
mint0: string
|
|
46
|
+
mint1: string
|
|
47
|
+
vault0: string
|
|
48
|
+
vault1: string
|
|
49
|
+
observation: string
|
|
50
|
+
tickSpacing: number
|
|
51
|
+
liquidity: bigint
|
|
52
|
+
sqrtPrice: bigint
|
|
53
|
+
tick: number
|
|
54
|
+
status: number
|
|
55
|
+
feeOn: number
|
|
56
|
+
bitmap: bigint
|
|
57
|
+
openTime: bigint
|
|
58
|
+
dynamicFee: DynamicFee | null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* PoolState:amm_config 9、mint 73 / 105、vault 137 / 169、observation 201、tick_spacing 235、liquidity 237、sqrt_price 253、tick 269、
|
|
63
|
+
* status 389、fee_on 390、tick_array_bitmap 904(16 个 u64)、open_time 1080、dynamic_fee_info 1096(80 字节,全 0 表示没有)
|
|
64
|
+
*/
|
|
65
|
+
export function parseRaydiumClmmPool(account: AccountInfo): RaydiumClmmState | null {
|
|
66
|
+
if (account.owner !== RAYDIUM_CLMM_PROGRAM_ID || account.data.length !== POOL_SIZE) return null
|
|
67
|
+
const b = new Bytes(account.data)
|
|
68
|
+
let bitmap = 0n
|
|
69
|
+
for (let i = 15; i >= 0; i--) bitmap = (bitmap << 64n) | b.u64(904 + i * 8)
|
|
70
|
+
const fee = account.data.subarray(1096, 1176)
|
|
71
|
+
const dynamicFee = fee.some((x) => x !== 0)
|
|
72
|
+
? {
|
|
73
|
+
filterPeriod: BigInt(b.u16(1096)),
|
|
74
|
+
decayPeriod: BigInt(b.u16(1098)),
|
|
75
|
+
reductionFactor: BigInt(b.u16(1100)),
|
|
76
|
+
control: BigInt(b.u32(1102)),
|
|
77
|
+
maxVolatility: BigInt(b.u32(1106)),
|
|
78
|
+
indexReference: b.i32(1110),
|
|
79
|
+
volatilityReference: BigInt(b.u32(1114)),
|
|
80
|
+
volatility: BigInt(b.u32(1118)),
|
|
81
|
+
lastUpdate: b.u64(1122),
|
|
82
|
+
}
|
|
83
|
+
: null
|
|
84
|
+
return {
|
|
85
|
+
address: account.address,
|
|
86
|
+
config: b.key(9),
|
|
87
|
+
mint0: b.key(73),
|
|
88
|
+
mint1: b.key(105),
|
|
89
|
+
vault0: b.key(137),
|
|
90
|
+
vault1: b.key(169),
|
|
91
|
+
observation: b.key(201),
|
|
92
|
+
tickSpacing: b.u16(235),
|
|
93
|
+
liquidity: b.u128(237),
|
|
94
|
+
sqrtPrice: b.u128(253),
|
|
95
|
+
tick: b.i32(269),
|
|
96
|
+
status: b.u8(389),
|
|
97
|
+
feeOn: b.u8(390),
|
|
98
|
+
bitmap,
|
|
99
|
+
openTime: b.u64(1080),
|
|
100
|
+
dynamicFee,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const floorDiv = (a: number, b: number) => Math.floor(a / b)
|
|
105
|
+
const arrayStart = (tick: number, spacing: number) => floorDiv(tick, spacing * TICK_ARRAY_SIZE) * spacing * TICK_ARRAY_SIZE
|
|
106
|
+
const i128 = (v: bigint) => (v >= 1n << 127n ? v - (1n << 128n) : v)
|
|
107
|
+
|
|
108
|
+
/** 池子自带位图里这个 tick 数组是否已初始化;超出位图覆盖的范围时为 null */
|
|
109
|
+
function bitmapHas(state: RaydiumClmmState, start: number): boolean | null {
|
|
110
|
+
const multiplier = state.tickSpacing * TICK_ARRAY_SIZE
|
|
111
|
+
const bit = start / multiplier + BITMAP_SIZE
|
|
112
|
+
if (bit < 0 || bit >= 2 * BITMAP_SIZE) return null
|
|
113
|
+
return ((state.bitmap >> BigInt(bit)) & 1n) === 1n
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 兑换会经过的已初始化 tick 数组(当前的,以及方向上接下来的),最多 count 个;需要位图扩展时报不支持 */
|
|
117
|
+
function tickArrayStarts(state: RaydiumClmmState, zeroForOne: boolean, count: number): number[] {
|
|
118
|
+
const step = state.tickSpacing * TICK_ARRAY_SIZE
|
|
119
|
+
let start = arrayStart(state.tick, state.tickSpacing)
|
|
120
|
+
const current = bitmapHas(state, start)
|
|
121
|
+
if (current === null) throw new UnsupportedPool('Pool price is outside the default tick array bitmap (needs the bitmap extension account)')
|
|
122
|
+
const out = current ? [start] : []
|
|
123
|
+
while (out.length < count) {
|
|
124
|
+
start += zeroForOne ? -step : step
|
|
125
|
+
const has = bitmapHas(state, start)
|
|
126
|
+
if (has === null) break
|
|
127
|
+
if (has) out.push(start)
|
|
128
|
+
}
|
|
129
|
+
return out
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface TickState {
|
|
133
|
+
tick: number
|
|
134
|
+
liquidityNet: bigint
|
|
135
|
+
liquidityGross: bigint
|
|
136
|
+
/** 限价单未成交的数量(orders_amount + part_filled_orders_remaining) */
|
|
137
|
+
unfilled: bigint
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** TickArrayState:pool_id 8、start_tick_index 40、ticks 44(60 个,每个 168 字节:tick +0、liquidity_net +4、liquidity_gross +20、orders_amount +124、part_filled +132) */
|
|
141
|
+
function readTickArray(state: RaydiumClmmState, address: string, start: number, account: AccountInfo | null | undefined): TickState[] {
|
|
142
|
+
if (!account || account.owner !== RAYDIUM_CLMM_PROGRAM_ID || account.data.length !== TICK_ARRAY_LEN) {
|
|
143
|
+
throw new UnsupportedPool(`Tick array not loaded: ${address}`)
|
|
144
|
+
}
|
|
145
|
+
const b = new Bytes(account.data)
|
|
146
|
+
if (b.key(8) !== state.address || b.i32(40) !== start) throw new UnsupportedPool(`Unexpected tick array: ${address}`)
|
|
147
|
+
return Array.from({ length: TICK_ARRAY_SIZE }, (_, i) => {
|
|
148
|
+
const o = 44 + i * TICK_SIZE
|
|
149
|
+
return { tick: start + i * state.tickSpacing, liquidityNet: i128(b.u128(o + 4)), liquidityGross: b.u128(o + 20), unfilled: b.u64(o + 124) + b.u64(o + 132) }
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 兑换走到了没有读的 tick 数组 */
|
|
154
|
+
export class MissingTickArray extends Error {
|
|
155
|
+
constructor(readonly start: number) {
|
|
156
|
+
super(`Tick array ${start} is not loaded`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const initialized = (t: TickState) => t.liquidityGross > 0n || t.unfilled > 0n
|
|
161
|
+
|
|
162
|
+
/** 与程序 tick_spacing_index_from_tick 一致:向负无穷取整 */
|
|
163
|
+
function spacingIndex(tick: number, spacing: number): number {
|
|
164
|
+
return tick % spacing === 0 || tick >= 0 ? Math.trunc(tick / spacing) : Math.trunc(tick / spacing) - 1
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 动态费率(与程序 compute_dynamic_fee_rate 一致) */
|
|
168
|
+
function dynamicFeeRate(fee: DynamicFee, spacing: number): bigint {
|
|
169
|
+
const crossed = fee.volatility * BigInt(spacing)
|
|
170
|
+
const rate = mulDivCeil(fee.control, crossed * crossed, DYNAMIC_FEE_CONTROL_DENOMINATOR * VOLATILITY_SCALE * VOLATILITY_SCALE)
|
|
171
|
+
return rate > MAX_FEE_RATE ? MAX_FEE_RATE : rate
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** tick 的价格(Q64.64,price = sqrt² / 2^64) */
|
|
175
|
+
const priceOf = (sqrt: bigint, roundUp: boolean) => (sqrt * sqrt + (roundUp ? Q64 - 1n : 0n)) >> 64n
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 本地报价(swap_internal 的按输入数量部分)。amount 是扣掉输入转账手续费后进入池子的数量;
|
|
179
|
+
* 返回池子付出的输出(未扣输出转账手续费)和经过的 tick 数组
|
|
180
|
+
*/
|
|
181
|
+
export function raydiumSwap(
|
|
182
|
+
state: RaydiumClmmState,
|
|
183
|
+
arrays: ReadonlyMap<number, TickState[]>,
|
|
184
|
+
amount: bigint,
|
|
185
|
+
zeroForOne: boolean,
|
|
186
|
+
baseFeeRate: bigint,
|
|
187
|
+
timestamp: bigint,
|
|
188
|
+
): { amountIn: bigint; amountOut: bigint; starts: number[]; crossed: number; orders: number } {
|
|
189
|
+
const spacing = state.tickSpacing
|
|
190
|
+
const limit = zeroForOne ? RAYDIUM_MIN_SQRT_PRICE + 1n : RAYDIUM_MAX_SQRT_PRICE - 1n
|
|
191
|
+
if (amount === 0n) throw new SwapMathError('zero amount')
|
|
192
|
+
if (zeroForOne ? !(limit < state.sqrtPrice) : !(limit > state.sqrtPrice)) throw new SwapMathError('price is at the limit')
|
|
193
|
+
// 只读一次的 tick 数组按顺序取(与程序一样按位图找下一个)
|
|
194
|
+
const step = spacing * TICK_ARRAY_SIZE
|
|
195
|
+
const getArray = (start: number) => {
|
|
196
|
+
const ticks = arrays.get(start)
|
|
197
|
+
if (!ticks) throw new MissingTickArray(start)
|
|
198
|
+
return ticks
|
|
199
|
+
}
|
|
200
|
+
const nextArrayStart = (from: number): number => {
|
|
201
|
+
let s = from
|
|
202
|
+
for (;;) {
|
|
203
|
+
s += zeroForOne ? -step : step
|
|
204
|
+
const has = bitmapHas(state, s)
|
|
205
|
+
if (has === null) {
|
|
206
|
+
if (spacing >= 15) throw new UnsupportedPool('Insufficient liquidity')
|
|
207
|
+
throw new UnsupportedPool('Swap goes beyond the default tick array bitmap (needs the bitmap extension account)')
|
|
208
|
+
}
|
|
209
|
+
if (has) return s
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const currentStart = arrayStart(state.tick, spacing)
|
|
213
|
+
const currentInit = bitmapHas(state, currentStart)
|
|
214
|
+
if (currentInit === null) throw new UnsupportedPool('Pool price is outside the default tick array bitmap (needs the bitmap extension account)')
|
|
215
|
+
let firstContains = currentInit
|
|
216
|
+
let validStart = currentInit ? currentStart : nextArrayStart(currentStart)
|
|
217
|
+
let ticks = getArray(validStart)
|
|
218
|
+
const starts = [validStart]
|
|
219
|
+
|
|
220
|
+
const feeOnInput = state.feeOn === 1 ? zeroForOne : state.feeOn === 2 ? !zeroForOne : true
|
|
221
|
+
let remaining = amount
|
|
222
|
+
let calculated = 0n
|
|
223
|
+
let sqrt = state.sqrtPrice
|
|
224
|
+
let tick = state.tick
|
|
225
|
+
let liquidity = state.liquidity
|
|
226
|
+
// 动态费率:开始时按时间更新参考值
|
|
227
|
+
const fee = state.dynamicFee ? { ...state.dynamicFee } : null
|
|
228
|
+
let index = 0
|
|
229
|
+
if (fee) {
|
|
230
|
+
index = spacingIndex(tick, spacing)
|
|
231
|
+
const elapsed = timestamp > fee.lastUpdate ? timestamp - fee.lastUpdate : 0n
|
|
232
|
+
if (elapsed >= fee.filterPeriod) {
|
|
233
|
+
fee.indexReference = index
|
|
234
|
+
fee.volatilityReference = elapsed < fee.decayPeriod ? (fee.volatility * fee.reductionFactor) / REDUCTION_FACTOR_DENOMINATOR : 0n
|
|
235
|
+
fee.lastUpdate = timestamp
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const updateVolatility = () => {
|
|
239
|
+
if (!fee) return
|
|
240
|
+
const v = fee.volatilityReference + BigInt(Math.abs(fee.indexReference - index)) * VOLATILITY_SCALE
|
|
241
|
+
fee.volatility = v < fee.maxVolatility ? v : fee.maxVolatility
|
|
242
|
+
}
|
|
243
|
+
const apply = (amountIn: bigint, amountOut: bigint, feeAmount: bigint) => {
|
|
244
|
+
remaining -= feeOnInput ? amountIn + feeAmount : amountIn
|
|
245
|
+
calculated += amountOut
|
|
246
|
+
if (remaining < 0n) throw new SwapMathError('amount remaining overflow')
|
|
247
|
+
}
|
|
248
|
+
// 限价单剩余(按 tick):同一笔里可能被吃掉一部分
|
|
249
|
+
const unfilled = new Map<number, bigint>()
|
|
250
|
+
let crossed = 0
|
|
251
|
+
let orders = 0
|
|
252
|
+
|
|
253
|
+
while (remaining !== 0n && sqrt !== limit) {
|
|
254
|
+
// 下一个已初始化的 tick
|
|
255
|
+
let next: TickState | undefined
|
|
256
|
+
const inCurrent = arrayStart(tick, spacing) === validStart
|
|
257
|
+
if (inCurrent) {
|
|
258
|
+
const offset = Math.floor((tick - validStart) / spacing)
|
|
259
|
+
if (zeroForOne) {
|
|
260
|
+
for (let i = offset; i >= 0 && !next; i--) if (initialized(ticks[i] as TickState)) next = ticks[i]
|
|
261
|
+
} else {
|
|
262
|
+
for (let i = offset + 1; i < TICK_ARRAY_SIZE && !next; i++) if (initialized(ticks[i] as TickState)) next = ticks[i]
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (!next) {
|
|
266
|
+
if (!firstContains) {
|
|
267
|
+
firstContains = true
|
|
268
|
+
} else {
|
|
269
|
+
validStart = nextArrayStart(validStart)
|
|
270
|
+
ticks = getArray(validStart)
|
|
271
|
+
starts.push(validStart)
|
|
272
|
+
}
|
|
273
|
+
next = zeroForOne ? [...ticks].reverse().find(initialized) : ticks.find(initialized)
|
|
274
|
+
if (!next) throw new UnsupportedPool('Invalid tick array (no initialized tick)')
|
|
275
|
+
}
|
|
276
|
+
const nextState: TickState = { ...next, unfilled: unfilled.get(next.tick) ?? next.unfilled }
|
|
277
|
+
if (!initialized(nextState)) throw new SwapMathError('tick is not initialized')
|
|
278
|
+
const tickNext = Math.min(Math.max(nextState.tick, MIN_TICK), MAX_TICK)
|
|
279
|
+
const sqrtNextTick = raydiumSqrtPriceAtTick(tickNext)
|
|
280
|
+
const target = (zeroForOne && sqrtNextTick < limit) || (!zeroForOne && sqrtNextTick > limit) ? limit : sqrtNextTick
|
|
281
|
+
|
|
282
|
+
let liquidityNext = liquidity
|
|
283
|
+
for (;;) {
|
|
284
|
+
updateVolatility()
|
|
285
|
+
let totalFee = baseFeeRate
|
|
286
|
+
if (fee) {
|
|
287
|
+
totalFee = baseFeeRate + dynamicFeeRate(fee, spacing)
|
|
288
|
+
if (totalFee > MAX_FEE_RATE) totalFee = MAX_FEE_RATE
|
|
289
|
+
}
|
|
290
|
+
// 动态费率:每次最多走到当前 tick 组(tickSpacing 个 tick)的边界
|
|
291
|
+
let skipped = true
|
|
292
|
+
let bounded = target
|
|
293
|
+
let boundedTick: number | null = null
|
|
294
|
+
if (fee && liquidity !== 0n && fee.volatility !== fee.maxVolatility) {
|
|
295
|
+
skipped = false
|
|
296
|
+
const bt = Math.min(Math.max(zeroForOne ? index * spacing : (index + 1) * spacing, MIN_TICK), MAX_TICK)
|
|
297
|
+
const bs = raydiumSqrtPriceAtTick(bt)
|
|
298
|
+
if (zeroForOne ? target <= bs : target >= bs) {
|
|
299
|
+
bounded = bs
|
|
300
|
+
boundedTick = bt
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
let stepPrice = bounded
|
|
304
|
+
if (sqrt !== bounded) {
|
|
305
|
+
const s = computeSwapStep(sqrt, bounded, liquidity, remaining, totalFee, zeroForOne, feeOnInput, 'raydium')
|
|
306
|
+
apply(s.amountIn, s.amountOut, s.feeAmount)
|
|
307
|
+
stepPrice = s.sqrtPrice
|
|
308
|
+
}
|
|
309
|
+
if (sqrtNextTick === stepPrice) {
|
|
310
|
+
// 走到 tick:先按 tick 价格和限价单成交
|
|
311
|
+
const total = nextState.unfilled
|
|
312
|
+
if (remaining !== 0n && total !== 0n) {
|
|
313
|
+
const price0 = priceOf(sqrtNextTick, !zeroForOne)
|
|
314
|
+
let lin: bigint
|
|
315
|
+
let lfee = 0n
|
|
316
|
+
let lout: bigint
|
|
317
|
+
if (feeOnInput) {
|
|
318
|
+
lfee = mulDivCeil(remaining, totalFee, FEE_RATE_DENOMINATOR)
|
|
319
|
+
lin = remaining - lfee
|
|
320
|
+
} else {
|
|
321
|
+
lin = remaining
|
|
322
|
+
}
|
|
323
|
+
const matched = zeroForOne ? (lin * price0) / Q64 : (lin * Q64) / price0
|
|
324
|
+
if (matched > total) {
|
|
325
|
+
lout = total
|
|
326
|
+
lin = zeroForOne ? mulDivCeil(total, Q64, price0) : mulDivCeil(total, price0, Q64)
|
|
327
|
+
if (lin > U64_MAX) throw new SwapMathError('limit order input overflow')
|
|
328
|
+
if (feeOnInput) lfee = mulDivCeil(lin, totalFee, FEE_RATE_DENOMINATOR - totalFee)
|
|
329
|
+
} else {
|
|
330
|
+
lout = matched
|
|
331
|
+
}
|
|
332
|
+
nextState.unfilled = total - lout
|
|
333
|
+
unfilled.set(nextState.tick, nextState.unfilled)
|
|
334
|
+
if (!feeOnInput) {
|
|
335
|
+
lfee = mulDivCeil(lout, totalFee, FEE_RATE_DENOMINATOR)
|
|
336
|
+
lout -= lfee
|
|
337
|
+
}
|
|
338
|
+
if (lin !== 0n || lout !== 0n || lfee !== 0n) {
|
|
339
|
+
apply(lin, lout, lfee)
|
|
340
|
+
orders++
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const hasOrders = nextState.unfilled > 0n
|
|
344
|
+
if (nextState.liquidityGross > 0n && !hasOrders) {
|
|
345
|
+
liquidityNext = liquidity + (zeroForOne ? -nextState.liquidityNet : nextState.liquidityNet)
|
|
346
|
+
crossed++
|
|
347
|
+
if (liquidityNext < 0n) throw new SwapMathError('liquidity underflow')
|
|
348
|
+
}
|
|
349
|
+
tick = (zeroForOne && !hasOrders) || (!zeroForOne && hasOrders) ? tickNext - 1 : tickNext
|
|
350
|
+
} else if (sqrt !== stepPrice) {
|
|
351
|
+
tick = boundedTick !== null && stepPrice === bounded ? boundedTick : tickAtSqrtPrice('raydium', stepPrice)
|
|
352
|
+
}
|
|
353
|
+
sqrt = stepPrice
|
|
354
|
+
if (fee) {
|
|
355
|
+
if (skipped) {
|
|
356
|
+
const t = sqrt === sqrtNextTick ? tickNext : tick
|
|
357
|
+
let idx = spacingIndex(t, spacing)
|
|
358
|
+
if (!zeroForOne && t % spacing === 0) idx -= 1
|
|
359
|
+
index = idx
|
|
360
|
+
if (fee.volatility !== fee.maxVolatility) updateVolatility()
|
|
361
|
+
}
|
|
362
|
+
index += zeroForOne ? -1 : 1
|
|
363
|
+
}
|
|
364
|
+
if (remaining === 0n || sqrt === target) break
|
|
365
|
+
}
|
|
366
|
+
liquidity = liquidityNext
|
|
367
|
+
}
|
|
368
|
+
// 没有给价格上限时不允许部分成交
|
|
369
|
+
if (remaining !== 0n) throw new UnsupportedPool('Insufficient liquidity')
|
|
370
|
+
return { amountIn: amount, amountOut: calculated, starts, crossed, orders }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function raydiumClmmPool(state: RaydiumClmmState): SwapPool {
|
|
374
|
+
const mints = (get: AccountGetter): [SwapMint, SwapMint] => [
|
|
375
|
+
swapMint(state.mint0, need(get, state.mint0, 'Token mint')),
|
|
376
|
+
swapMint(state.mint1, need(get, state.mint1, 'Token mint')),
|
|
377
|
+
]
|
|
378
|
+
const arrayAddress = (start: number) => raydiumTickArray(state.address, start, state.tickSpacing).address
|
|
379
|
+
return {
|
|
380
|
+
protocol: 'raydium-clmm',
|
|
381
|
+
address: state.address,
|
|
382
|
+
mint0: state.mint0,
|
|
383
|
+
mint1: state.mint1,
|
|
384
|
+
mints,
|
|
385
|
+
accounts(zeroForOne) {
|
|
386
|
+
let starts: number[] = []
|
|
387
|
+
try {
|
|
388
|
+
starts = tickArrayStarts(state, zeroForOne, INITIAL_TICK_ARRAYS)
|
|
389
|
+
} catch {
|
|
390
|
+
// 报价时再报不支持
|
|
391
|
+
}
|
|
392
|
+
return [state.config, state.mint0, state.mint1, ...starts.map(arrayAddress)]
|
|
393
|
+
},
|
|
394
|
+
quote(get, amountIn, zeroForOne, env: SwapEnv): HopQuote {
|
|
395
|
+
// status 第 4 位(Swap)为 1 表示禁止兑换
|
|
396
|
+
if (state.status & 16) throw new UnsupportedPool('Swap is disabled on this pool')
|
|
397
|
+
if (!(env.timestamp > state.openTime)) throw new UnsupportedPool('Pool is not open yet')
|
|
398
|
+
const [m0, m1] = mints(get)
|
|
399
|
+
const config = need(get, state.config, 'AMM config')
|
|
400
|
+
if (config.data.length < 57) throw new UnsupportedPool('Unexpected AMM config')
|
|
401
|
+
// AmmConfig:protocol_fee_rate 43、trade_fee_rate 47、fund_fee_rate 53(u32)
|
|
402
|
+
const baseFee = BigInt(new Bytes(config.data).u32(47))
|
|
403
|
+
// 已经读到的 tick 数组(按位图顺序,读到第一个没读的为止)
|
|
404
|
+
const all = tickArrayStarts(state, zeroForOne, MAX_TICK_ARRAYS)
|
|
405
|
+
const arrays = new Map<number, TickState[]>()
|
|
406
|
+
for (const start of all) {
|
|
407
|
+
const address = arrayAddress(start)
|
|
408
|
+
const account = get(address)
|
|
409
|
+
if (account === undefined) break
|
|
410
|
+
arrays.set(start, readTickArray(state, address, start, account))
|
|
411
|
+
}
|
|
412
|
+
const [mintIn, mintOut] = zeroForOne ? [m0, m1] : [m1, m0]
|
|
413
|
+
const into = amountIn - transferFee(mintIn, amountIn, env.epoch)
|
|
414
|
+
if (into <= 0n) return { amountIn, amountOut: 0n, accounts: [] }
|
|
415
|
+
let result: ReturnType<typeof raydiumSwap>
|
|
416
|
+
try {
|
|
417
|
+
result = raydiumSwap(state, arrays, into, zeroForOne, baseFee, env.timestamp)
|
|
418
|
+
} catch (err) {
|
|
419
|
+
if (!(err instanceof MissingTickArray)) throw err
|
|
420
|
+
const more = all.filter((s) => !arrays.has(s)).slice(0, 3)
|
|
421
|
+
if (!more.length) throw new UnsupportedPool(`The swap needs more than ${MAX_TICK_ARRAYS} tick arrays (amount too large for this pool)`)
|
|
422
|
+
throw new NeedAccounts(more.map(arrayAddress))
|
|
423
|
+
}
|
|
424
|
+
if (result.amountOut === 0n) return { amountIn, amountOut: 0n, accounts: [] }
|
|
425
|
+
// 交易里传经过的 tick 数组,再多带一个(价格在发送前变动时用)
|
|
426
|
+
const loaded = [...arrays.keys()]
|
|
427
|
+
const extra = loaded.find((s) => !result.starts.includes(s) && (zeroForOne ? s < (result.starts.at(-1) as number) : s > (result.starts.at(-1) as number)))
|
|
428
|
+
const used = extra === undefined ? result.starts : [...result.starts, extra]
|
|
429
|
+
return {
|
|
430
|
+
amountIn,
|
|
431
|
+
amountOut: result.amountOut - transferFee(mintOut, result.amountOut, env.epoch),
|
|
432
|
+
accounts: used.map(arrayAddress),
|
|
433
|
+
crossed: result.crossed,
|
|
434
|
+
orders: result.orders,
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
instruction(get, owner, input, output, zeroForOne, quote, minOut): Instruction {
|
|
438
|
+
const [m0, m1] = mints(get)
|
|
439
|
+
const [mintIn, mintOut] = zeroForOne ? [m0, m1] : [m1, m0]
|
|
440
|
+
const [vaultIn, vaultOut] = zeroForOne ? [state.vault0, state.vault1] : [state.vault1, state.vault0]
|
|
441
|
+
return {
|
|
442
|
+
programId: RAYDIUM_CLMM_PROGRAM_ID,
|
|
443
|
+
keys: [
|
|
444
|
+
r(owner, true),
|
|
445
|
+
r(state.config),
|
|
446
|
+
w(state.address),
|
|
447
|
+
w(input),
|
|
448
|
+
w(output),
|
|
449
|
+
w(vaultIn),
|
|
450
|
+
w(vaultOut),
|
|
451
|
+
w(state.observation),
|
|
452
|
+
r(TOKEN_PROGRAM_ID),
|
|
453
|
+
r(TOKEN_2022_PROGRAM_ID),
|
|
454
|
+
r(MEMO_PROGRAM_ID),
|
|
455
|
+
r(mintIn.mint),
|
|
456
|
+
r(mintOut.mint),
|
|
457
|
+
...quote.accounts.map((a) => w(a)),
|
|
458
|
+
],
|
|
459
|
+
// sqrt_price_limit 0:不限价(程序要求全部成交);is_base_input true
|
|
460
|
+
data: new Writer().bytes(anchorIx('swap_v2')).u64(quote.amountIn).u64(minOut).u128(0n).bool(true).build(),
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
}
|
|
464
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 能不能买、能不能卖(/risk 的 tradeChecks):用 /swap 构建“SOL 买入 → 全部卖回 SOL”,放进一笔交易在主网模拟(simulateTransaction,不验签名、不上链),
|
|
3
|
+
* 看哪一步失败、来回损失多少。和 tokenRisks 分开:不用时打包不会带上 /swap 的代码
|
|
4
|
+
*/
|
|
5
|
+
import { base64 } from '@scure/base'
|
|
6
|
+
|
|
7
|
+
import { getAssociatedTokenAddress, isAddress } from './address.js'
|
|
8
|
+
import type { RequestOptions, SolanaClient } from './client.js'
|
|
9
|
+
import { NATIVE_MINT, TOKEN_PROGRAM_ID } from './constants.js'
|
|
10
|
+
import { parseTokenAccount, parseUnits } from './layout.js'
|
|
11
|
+
import { buildSwap, type SolanaSwapOptions, type SolanaSwapPlan } from './swap/index.js'
|
|
12
|
+
import { MAX_COMPUTE_UNITS, setComputeUnitLimit } from './write/instructions.js'
|
|
13
|
+
import { transactionErrorText } from './write/send.js'
|
|
14
|
+
import { MAX_ACCOUNT_LOCKS, PACKET_DATA_SIZE, compileMessage, serializeTransaction, transactionSize, type Instruction } from './write/transaction.js'
|
|
15
|
+
|
|
16
|
+
export type TradeStage = 'ok' | 'no-pool' | 'buy-failed' | 'sell-failed'
|
|
17
|
+
|
|
18
|
+
export interface SolanaTradeCheck {
|
|
19
|
+
mint: string
|
|
20
|
+
/** ok:买入、卖出都成功;no-pool:内置协议里找不到能报价的路线;buy-failed:买不进;sell-failed:买得进、卖不出(貔貅盘);模拟没做成时为 null */
|
|
21
|
+
stage: TradeStage | null
|
|
22
|
+
/** 卖不出 */
|
|
23
|
+
honeypot: boolean | null
|
|
24
|
+
/** 买入用的路线(池子、协议) */
|
|
25
|
+
buyRoute: Array<{ protocol: string; pool: string }> | null
|
|
26
|
+
/** 花的 SOL、买到的代币、卖回的 SOL(最小单位) */
|
|
27
|
+
spent: string
|
|
28
|
+
bought: string | null
|
|
29
|
+
sold: string | null
|
|
30
|
+
/** 来回损失(0~1):1 − 卖回的 SOL / 花的 SOL(卖出的是买入的最少收到,按卖出的比例折算),含两次的池子手续费、价格影响和转账手续费 */
|
|
31
|
+
roundTripLoss: number | null
|
|
32
|
+
/** 失败原因(模拟的报错或报价的报错) */
|
|
33
|
+
error: string | null
|
|
34
|
+
/** 汇总的标记:no-pool、cannot-buy、honeypot、high-round-trip-loss(> 10%) */
|
|
35
|
+
flags: string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TradeCheckOptions extends RequestOptions, Pick<SolanaSwapOptions, 'protocols' | 'intermediates' | 'pools' | 'discover'> {
|
|
39
|
+
/**
|
|
40
|
+
* 模拟用的钱包(要有足够的 SOL;不验签名,可以是任何有 SOL 的系统账户,如自己的地址)。
|
|
41
|
+
* 模拟时它创建代币账户的租金和手续费都不会真的扣
|
|
42
|
+
*/
|
|
43
|
+
owner: string
|
|
44
|
+
/** 买入花多少 SOL:amount(lamports)和 amountFormatted 二选一,默认 0.05 SOL */
|
|
45
|
+
amount?: bigint | number | string
|
|
46
|
+
amountFormatted?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 模拟买入再卖出,判断能不能交易(每个代币两次报价 + 一次 simulateTransaction)。
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* const [check] = await tradeChecks(client, [mint], { owner: myAddress })
|
|
54
|
+
* // { stage: 'ok', honeypot: false, roundTripLoss: 0.0061, flags: [] }
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export async function tradeChecks(client: SolanaClient, mints: readonly string[], options: TradeCheckOptions): Promise<SolanaTradeCheck[]> {
|
|
58
|
+
if (!isAddress(options.owner)) throw new Error(`Invalid owner address: ${options.owner}`)
|
|
59
|
+
if (options.amount !== undefined && options.amountFormatted !== undefined) throw new Error('Pass either amount or amountFormatted, not both')
|
|
60
|
+
const spent = options.amountFormatted !== undefined ? parseUnits(options.amountFormatted, 9) : options.amount !== undefined ? BigInt(options.amount) : 50_000_000n
|
|
61
|
+
if (spent <= 0n) throw new RangeError('amount must be greater than 0')
|
|
62
|
+
const [sol] = await client.solBalances([options.owner], { signal: options.signal })
|
|
63
|
+
// 留出两个代币账户的租金和手续费
|
|
64
|
+
if (!sol?.success || BigInt(sol.balance) < spent + 10_000_000n) {
|
|
65
|
+
throw new Error(`${options.owner} does not have enough SOL to simulate the trades (needs about ${spent + 10_000_000n} lamports)`)
|
|
66
|
+
}
|
|
67
|
+
return Promise.all(mints.map((mint) => checkOne(client, mint, spent, options)))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function checkOne(client: SolanaClient, mint: string, spent: bigint, options: TradeCheckOptions): Promise<SolanaTradeCheck> {
|
|
71
|
+
const base: SolanaTradeCheck = { mint, stage: null, honeypot: null, buyRoute: null, spent: spent.toString(), bought: null, sold: null, roundTripLoss: null, error: null, flags: [] }
|
|
72
|
+
const swapOptions: SolanaSwapOptions = { protocols: options.protocols, intermediates: options.intermediates, pools: options.pools, discover: options.discover, signal: options.signal }
|
|
73
|
+
let buy: SolanaSwapPlan
|
|
74
|
+
try {
|
|
75
|
+
// 先只找直接兑换(买入、卖出放进一笔交易更容易放得下),没有时再找两跳
|
|
76
|
+
buy = await buildSwap(client, options.owner, { tokenIn: NATIVE_MINT, tokenOut: mint, amountIn: spent }, { ...swapOptions, intermediates: options.intermediates ?? [] }).catch((err: unknown) => {
|
|
77
|
+
if (options.intermediates || options.signal?.aborted) throw err
|
|
78
|
+
return buildSwap(client, options.owner, { tokenIn: NATIVE_MINT, tokenOut: mint, amountIn: spent }, swapOptions)
|
|
79
|
+
})
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (options.signal?.aborted) throw err
|
|
82
|
+
return { ...base, stage: 'no-pool', error: errorText(err), flags: ['no-pool'] }
|
|
83
|
+
}
|
|
84
|
+
const buyRoute = buy.route.hops.map((h) => ({ protocol: h.protocol, pool: h.pool }))
|
|
85
|
+
let sell: SolanaSwapPlan
|
|
86
|
+
try {
|
|
87
|
+
// 卖出按买入的最少收到算,走买入的同一条路线(反过来),最少换回不设下限(只看能不能卖、卖回多少)
|
|
88
|
+
sell = await buildSwap(
|
|
89
|
+
client,
|
|
90
|
+
options.owner,
|
|
91
|
+
{ tokenIn: mint, tokenOut: NATIVE_MINT, amountIn: buy.amountOutMin },
|
|
92
|
+
{ protocols: options.protocols, discover: false, pools: buy.route.hops.map((h) => h.pool), intermediates: buy.route.path.slice(1, -1), slippage: 0.999999, signal: options.signal },
|
|
93
|
+
)
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (options.signal?.aborted) throw err
|
|
96
|
+
// 同一条路线反过来报不了价(如走得超出 Orca 的 3 个 tick 数组):不能断定卖不出
|
|
97
|
+
return { ...base, buyRoute, error: `Cannot quote the sell: ${errorText(err)}` }
|
|
98
|
+
}
|
|
99
|
+
const tokenProgram = (await client.accounts([mint], { signal: options.signal }))[0]?.owner ?? TOKEN_PROGRAM_ID
|
|
100
|
+
const tokenAccount = getAssociatedTokenAddress(options.owner, mint, tokenProgram)
|
|
101
|
+
const wsol = getAssociatedTokenAddress(options.owner, NATIVE_MINT, TOKEN_PROGRAM_ID)
|
|
102
|
+
const buyIxs = buy.steps.flatMap((s) => s.instructions)
|
|
103
|
+
// 卖出最后不关闭 wSOL 账户:读模拟后的余额就是卖回的 SOL
|
|
104
|
+
const sellIxs = sell.steps.flatMap((s) => s.instructions).filter((ix, i, all) => !(i === all.length - 1 && ix.programId === TOKEN_PROGRAM_ID && ix.data[0] === 9))
|
|
105
|
+
const instructions: Instruction[] = [setComputeUnitLimit(MAX_COMPUTE_UNITS), ...buyIxs, ...sellIxs]
|
|
106
|
+
const message = compileMessage(options.owner, instructions, '11111111111111111111111111111111')
|
|
107
|
+
if (transactionSize(message) > PACKET_DATA_SIZE || message.accountKeys.length > MAX_ACCOUNT_LOCKS) {
|
|
108
|
+
return { ...base, buyRoute, error: 'The buy and sell routes do not fit in one transaction' }
|
|
109
|
+
}
|
|
110
|
+
const [[before, tokenBefore], sim] = await Promise.all([
|
|
111
|
+
client.accounts([wsol, tokenAccount], { signal: options.signal }),
|
|
112
|
+
client.request<{ value: { err: unknown; logs: string[] | null; accounts: Array<{ data: [string, string] } | null> | null } }>(
|
|
113
|
+
'simulateTransaction',
|
|
114
|
+
[
|
|
115
|
+
base64.encode(serializeTransaction(message, message.signers.map(() => null))),
|
|
116
|
+
{ encoding: 'base64', sigVerify: false, replaceRecentBlockhash: true, commitment: 'confirmed', accounts: { encoding: 'base64', addresses: [tokenAccount, wsol] } },
|
|
117
|
+
],
|
|
118
|
+
{ signal: options.signal },
|
|
119
|
+
),
|
|
120
|
+
])
|
|
121
|
+
if (sim.value.err) {
|
|
122
|
+
const failed = (sim.value.err as { InstructionError?: [number, unknown] }).InstructionError?.[0]
|
|
123
|
+
const error = transactionErrorText(sim.value.err, sim.value.logs)
|
|
124
|
+
// 第 0 条是计算单元上限;之后是买入的指令
|
|
125
|
+
if (failed === undefined) return { ...base, buyRoute, error }
|
|
126
|
+
if (failed <= buyIxs.length) return { ...base, buyRoute, stage: 'buy-failed', error, flags: ['cannot-buy'] }
|
|
127
|
+
return { ...base, buyRoute, stage: 'sell-failed', honeypot: true, error, flags: ['honeypot'] }
|
|
128
|
+
}
|
|
129
|
+
const amountOf = (a: { data: [string, string] } | null | undefined) => {
|
|
130
|
+
try {
|
|
131
|
+
return a ? parseTokenAccount(base64.decode(a.data[0])).amount : 0n
|
|
132
|
+
} catch {
|
|
133
|
+
return 0n
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const [afterToken, afterWsol] = sim.value.accounts ?? []
|
|
137
|
+
const prior = before ? safeAmount(before.data) : 0n
|
|
138
|
+
// 原来就有 wSOL:买入先用掉已有的 wSOL,不够的部分包装
|
|
139
|
+
const sold = amountOf(afterWsol) - (prior > spent ? prior - spent : 0n)
|
|
140
|
+
// 实际买到的 = 卖出的(买入的最少收到)+ 卖完后代币账户里多出来的
|
|
141
|
+
const bought = BigInt(buy.amountOutMin) + amountOf(afterToken) - (tokenBefore ? safeAmount(tokenBefore.data) : 0n)
|
|
142
|
+
// 只卖出了买入的最少收到(买到的一部分):按卖出的比例折算花掉的 SOL
|
|
143
|
+
const sellAmount = BigInt(buy.amountOutMin)
|
|
144
|
+
const roundTripLoss = bought > 0n ? Math.max(0, 1 - (Number(sold) * Number(bought)) / (Number(spent) * Number(sellAmount))) : null
|
|
145
|
+
const flags = roundTripLoss !== null && roundTripLoss > 0.1 ? ['high-round-trip-loss'] : []
|
|
146
|
+
return {
|
|
147
|
+
...base,
|
|
148
|
+
buyRoute,
|
|
149
|
+
stage: 'ok',
|
|
150
|
+
honeypot: false,
|
|
151
|
+
bought: bought.toString(),
|
|
152
|
+
sold: sold.toString(),
|
|
153
|
+
roundTripLoss,
|
|
154
|
+
flags,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function safeAmount(data: Uint8Array): bigint {
|
|
159
|
+
try {
|
|
160
|
+
return parseTokenAccount(data).amount
|
|
161
|
+
} catch {
|
|
162
|
+
return 0n
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err))
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ASSOCIATED_TOKEN_PROGRAM_ID, SYSTEM_PROGRAM_ID } from '../constants.js'
|
|
1
|
+
import { ASSOCIATED_TOKEN_PROGRAM_ID, SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from '../constants.js'
|
|
2
2
|
import type { Instruction } from './transaction.js'
|
|
3
3
|
|
|
4
4
|
export const COMPUTE_BUDGET_PROGRAM_ID = 'ComputeBudget111111111111111111111111111111'
|
|
@@ -74,6 +74,31 @@ export function closeAccount(account: string, destination: string, owner: string
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** 撤销代币账户的授权(Approve 设置的 delegate),owner 签名 */
|
|
78
|
+
export function revoke(account: string, owner: string, tokenProgram: string): Instruction {
|
|
79
|
+
return {
|
|
80
|
+
programId: tokenProgram,
|
|
81
|
+
keys: [
|
|
82
|
+
{ pubkey: account, isSigner: false, isWritable: true },
|
|
83
|
+
{ pubkey: owner, isSigner: true, isWritable: false },
|
|
84
|
+
],
|
|
85
|
+
data: new Uint8Array([5]),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Token-2022 HarvestWithheldTokensToMint:把代币账户里暂存的转账手续费收到 mint 上(任何人都可以调用,不需要签名)。
|
|
91
|
+
* 账户里有未提取的手续费时关不掉,关闭前先调用它
|
|
92
|
+
*/
|
|
93
|
+
export function harvestWithheldTokensToMint(mint: string, accounts: readonly string[]): Instruction {
|
|
94
|
+
return {
|
|
95
|
+
programId: TOKEN_2022_PROGRAM_ID,
|
|
96
|
+
keys: [{ pubkey: mint, isSigner: false, isWritable: true }, ...accounts.map((pubkey) => ({ pubkey, isSigner: false, isWritable: true }))],
|
|
97
|
+
// TransferFeeExtension(26)的 HarvestWithheldTokensToMint(4)
|
|
98
|
+
data: new Uint8Array([26, 4]),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
export function setComputeUnitLimit(units: number): Instruction {
|
|
78
103
|
return {
|
|
79
104
|
programId: COMPUTE_BUDGET_PROGRAM_ID,
|