@w3lib/evm-batch-call 0.5.0 → 0.6.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 +14 -0
- package/README.md +79 -2
- package/dist/liquidity/common.d.ts +72 -0
- package/dist/liquidity/common.d.ts.map +1 -0
- package/dist/liquidity/common.js +271 -0
- package/dist/liquidity/common.js.map +1 -0
- package/dist/liquidity/concentrated.d.ts +19 -0
- package/dist/liquidity/concentrated.d.ts.map +1 -0
- package/dist/liquidity/concentrated.js +106 -0
- package/dist/liquidity/concentrated.js.map +1 -0
- package/dist/liquidity/index.d.ts +42 -0
- package/dist/liquidity/index.d.ts.map +1 -0
- package/dist/liquidity/index.js +143 -0
- package/dist/liquidity/index.js.map +1 -0
- package/dist/liquidity/types.d.ts +182 -0
- package/dist/liquidity/types.d.ts.map +1 -0
- package/dist/liquidity/types.js +2 -0
- package/dist/liquidity/types.js.map +1 -0
- package/dist/liquidity/v2.d.ts +5 -0
- package/dist/liquidity/v2.d.ts.map +1 -0
- package/dist/liquidity/v2.js +342 -0
- package/dist/liquidity/v2.js.map +1 -0
- package/dist/liquidity/v3.d.ts +6 -0
- package/dist/liquidity/v3.d.ts.map +1 -0
- package/dist/liquidity/v3.js +310 -0
- package/dist/liquidity/v3.js.map +1 -0
- package/dist/liquidity/v4.d.ts +5 -0
- package/dist/liquidity/v4.d.ts.map +1 -0
- package/dist/liquidity/v4.js +279 -0
- package/dist/liquidity/v4.js.map +1 -0
- package/dist/lp.d.ts +8 -1
- package/dist/lp.d.ts.map +1 -1
- package/dist/lp.js +29 -18
- package/dist/lp.js.map +1 -1
- package/dist/lpMath.d.ts +55 -0
- package/dist/lpMath.d.ts.map +1 -1
- package/dist/lpMath.js +209 -0
- package/dist/lpMath.js.map +1 -1
- package/dist/subpaths/liquidity.d.ts +24 -0
- package/dist/subpaths/liquidity.d.ts.map +1 -0
- package/dist/subpaths/liquidity.js +21 -0
- package/dist/subpaths/liquidity.js.map +1 -0
- package/dist/subpaths/lp.d.ts +1 -1
- package/dist/subpaths/lp.d.ts.map +1 -1
- package/dist/subpaths/lp.js +1 -1
- package/dist/subpaths/lp.js.map +1 -1
- package/dist/write/tron.d.ts +2 -0
- package/dist/write/tron.d.ts.map +1 -1
- package/dist/write/tron.js +4 -0
- package/dist/write/tron.js.map +1 -1
- package/package.json +20 -12
- package/src/liquidity/common.ts +314 -0
- package/src/liquidity/concentrated.ts +121 -0
- package/src/liquidity/index.ts +171 -0
- package/src/liquidity/types.ts +194 -0
- package/src/liquidity/v2.ts +371 -0
- package/src/liquidity/v3.ts +382 -0
- package/src/liquidity/v4.ts +348 -0
- package/src/lp.ts +36 -18
- package/src/lpMath.ts +223 -0
- package/src/subpaths/liquidity.ts +52 -0
- package/src/subpaths/lp.ts +20 -1
- package/src/write/tron.ts +5 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { Interface, MaxUint256, ZeroAddress, getAddress, getBigInt, parseUnits, type BigNumberish } from 'ethers'
|
|
2
|
+
|
|
3
|
+
import { formatUnits } from '../erc20.js'
|
|
4
|
+
import { LP_PROTOCOLS, type LpPosition, type LpProtocol, type LpToken, type LpVersion } from '../lp.js'
|
|
5
|
+
import { sqrtPriceToPrice, tickToPrice } from '../lpMath.js'
|
|
6
|
+
import type { Provider } from '../provider.js'
|
|
7
|
+
import { isTronAddress, isTronChain, toEvmAddress, toTronAddress } from '../tron.js'
|
|
8
|
+
import type { LiquidityOptions, LiquidityPlan, LiquidityTx, PositionRef } from './types.js'
|
|
9
|
+
|
|
10
|
+
export const ZERO_ADDRESS = ZeroAddress
|
|
11
|
+
export { MAX_UINT128 } from '../lpMath.js'
|
|
12
|
+
const MAX_UINT160 = 2n ** 160n - 1n
|
|
13
|
+
/** Uniswap 的 Permit2(各 EVM 链上地址相同),V4 的仓位合约通过它收取代币 */
|
|
14
|
+
export const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3'
|
|
15
|
+
/** Permit2 授权的有效期:30 天(与 Uniswap 网页一致) */
|
|
16
|
+
const PERMIT2_EXPIRATION = 30 * 24 * 3600
|
|
17
|
+
|
|
18
|
+
export const erc20Interface = new Interface([
|
|
19
|
+
'function allowance(address owner, address spender) view returns (uint256)',
|
|
20
|
+
'function approve(address spender, uint256 amount) returns (bool)',
|
|
21
|
+
])
|
|
22
|
+
const permit2Interface = new Interface([
|
|
23
|
+
'function allowance(address owner, address token, address spender) view returns (uint160 amount, uint48 expiration, uint48 nonce)',
|
|
24
|
+
'function approve(address token, address spender, uint160 amount, uint48 expiration)',
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
/** 一次构建用到的公共信息 */
|
|
28
|
+
export interface Ctx {
|
|
29
|
+
provider: Provider
|
|
30
|
+
chainId: number
|
|
31
|
+
tron: boolean
|
|
32
|
+
/** 0x 格式(ABI 编码用) */
|
|
33
|
+
owner: string
|
|
34
|
+
recipient: string
|
|
35
|
+
slippage: number
|
|
36
|
+
/** 截止时间(秒级时间戳) */
|
|
37
|
+
deadline: number
|
|
38
|
+
options: LiquidityOptions
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 地址统一成 0x 的校验和格式(Tron 的 T 地址先转换) */
|
|
42
|
+
export function evm(address: string): string {
|
|
43
|
+
return getAddress(isTronAddress(address) ? toEvmAddress(address) : address)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const same = (a: string, b: string) => evm(a) === evm(b)
|
|
47
|
+
|
|
48
|
+
/** 输出的地址:Tron 上转成 T 地址 */
|
|
49
|
+
export function out(ctx: Pick<Ctx, 'tron'>, address: string): string {
|
|
50
|
+
return ctx.tron ? toTronAddress(address) : address
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function createCtx(provider: Provider, owner: string, options: LiquidityOptions): Promise<Ctx> {
|
|
54
|
+
const chainId = await provider.getChainId()
|
|
55
|
+
const slippage = options.slippage ?? 0.005
|
|
56
|
+
if (!(slippage >= 0 && slippage < 1)) {
|
|
57
|
+
throw new RangeError(`slippage must be in [0, 1): ${slippage}`)
|
|
58
|
+
}
|
|
59
|
+
const seconds = options.deadline ?? 1200
|
|
60
|
+
if (!(seconds > 0)) {
|
|
61
|
+
throw new RangeError(`deadline must be greater than 0: ${seconds}`)
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
provider,
|
|
65
|
+
chainId,
|
|
66
|
+
tron: isTronChain(chainId),
|
|
67
|
+
owner: evm(owner),
|
|
68
|
+
recipient: evm(options.recipient ?? owner),
|
|
69
|
+
slippage,
|
|
70
|
+
deadline: Math.floor(Date.now() / 1000) + Math.floor(seconds),
|
|
71
|
+
options,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 是否是主币地址(NATIVE_TOKEN / 零地址,或 Provider 配置的主币地址) */
|
|
76
|
+
export function isNative(ctx: Ctx, address: string): boolean {
|
|
77
|
+
return evm(address) === ZERO_ADDRESS || ctx.provider.isNativeToken(address) || ctx.provider.isNativeToken(evm(address))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 代币信息(symbol / 精度);零地址是 V4 的主币 */
|
|
81
|
+
export async function tokenInfo(ctx: Ctx, addresses: readonly string[]): Promise<LpToken[]> {
|
|
82
|
+
const nativeAlias = ctx.provider.isNativeToken(ZERO_ADDRESS) ? undefined : ctx.provider.nativeTokens[0]
|
|
83
|
+
const query = addresses.map((a) => (a === ZERO_ADDRESS && nativeAlias !== undefined ? nativeAlias : a))
|
|
84
|
+
const details = await ctx.provider.tokens(query, { fields: ['symbol', 'decimals'] })
|
|
85
|
+
return addresses.map((address, i) => {
|
|
86
|
+
const d = details[i]
|
|
87
|
+
return { address: out(ctx, address), native: d?.native ?? false, symbol: d?.symbol ?? null, decimals: d?.decimals ?? null }
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function fmt(value: bigint, token: LpToken): string | null {
|
|
92
|
+
return token.decimals === null ? null : formatUnits(value, token.decimals)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 解析输入的数量:amount(最小单位)和 formatted 二选一,都没给时返回 undefined */
|
|
96
|
+
export function parseAmount(raw: BigNumberish | undefined, formatted: string | undefined, token: LpToken, name: string): bigint | undefined {
|
|
97
|
+
if (raw !== undefined && formatted !== undefined) {
|
|
98
|
+
throw new Error(`Pass either ${name} or ${name}Formatted, not both`)
|
|
99
|
+
}
|
|
100
|
+
let value: bigint
|
|
101
|
+
if (formatted !== undefined) {
|
|
102
|
+
if (token.decimals === null) {
|
|
103
|
+
throw new Error(`Cannot read the decimals of ${token.address}; pass ${name} in raw units instead of ${name}Formatted`)
|
|
104
|
+
}
|
|
105
|
+
value = parseUnits(formatted, token.decimals)
|
|
106
|
+
} else if (raw !== undefined) {
|
|
107
|
+
value = getBigInt(raw)
|
|
108
|
+
} else {
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
if (value < 0n) {
|
|
112
|
+
throw new RangeError(`${name} must not be negative`)
|
|
113
|
+
}
|
|
114
|
+
return value
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 按滑点向下取:value × (1 − slippage) */
|
|
118
|
+
export function minus(value: bigint, slippage: number): bigint {
|
|
119
|
+
return (value * (1_000_000n - BigInt(Math.round(slippage * 1e6)))) / 1_000_000n
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 移除的数量:percent(百分数)或 liquidity,二选一,默认全部 */
|
|
123
|
+
export function removeAmount(total: bigint, percent: number | undefined, liquidity: BigNumberish | undefined): bigint {
|
|
124
|
+
if (percent !== undefined && liquidity !== undefined) {
|
|
125
|
+
throw new Error('Pass either percent or liquidity, not both')
|
|
126
|
+
}
|
|
127
|
+
if (liquidity !== undefined) {
|
|
128
|
+
const value = getBigInt(liquidity)
|
|
129
|
+
if (value <= 0n || value > total) {
|
|
130
|
+
throw new RangeError(`liquidity must be in (0, ${total}]: ${value}`)
|
|
131
|
+
}
|
|
132
|
+
return value
|
|
133
|
+
}
|
|
134
|
+
const p = percent ?? 100
|
|
135
|
+
if (!(p > 0 && p <= 100)) {
|
|
136
|
+
throw new RangeError(`percent must be in (0, 100]: ${p}`)
|
|
137
|
+
}
|
|
138
|
+
const amount = p === 100 ? total : (total * BigInt(Math.round(p * 10_000))) / 1_000_000n
|
|
139
|
+
if (amount === 0n) {
|
|
140
|
+
throw new RangeError(`${p}% of ${total} rounds to 0`)
|
|
141
|
+
}
|
|
142
|
+
return amount
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 选出协议:自定义的优先,再找内置表 */
|
|
146
|
+
function candidates(ctx: Ctx): LpProtocol[] {
|
|
147
|
+
return [...(ctx.options.protocols ?? []).filter((p) => p.chainId === ctx.chainId), ...LP_PROTOCOLS.filter((p) => p.chainId === ctx.chainId)]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function protocolById(ctx: Ctx, protocol: string | LpProtocol): LpProtocol {
|
|
151
|
+
if (typeof protocol !== 'string') {
|
|
152
|
+
if (protocol.chainId !== ctx.chainId) {
|
|
153
|
+
throw new Error(`Protocol ${protocol.id} is on chain ${protocol.chainId}, not ${ctx.chainId}`)
|
|
154
|
+
}
|
|
155
|
+
return protocol
|
|
156
|
+
}
|
|
157
|
+
const found = candidates(ctx).find((p) => p.id === protocol)
|
|
158
|
+
if (!found) {
|
|
159
|
+
throw new Error(`Unknown LP protocol ${protocol} on chain ${ctx.chainId}; pass an LpProtocol object (or options.protocols) for protocols that are not built in`)
|
|
160
|
+
}
|
|
161
|
+
return found
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** V3 / V4 仓位所属的协议:按 positionManager 找,没有时按协议 id */
|
|
165
|
+
export function concentratedProtocol(ctx: Ctx, ref: PositionRef | LpPosition): LpProtocol {
|
|
166
|
+
const pm = 'positionManager' in ref && ref.positionManager ? evm(ref.positionManager) : null
|
|
167
|
+
if (pm) {
|
|
168
|
+
const fromRef = ref.protocol && typeof ref.protocol !== 'string' ? [ref.protocol] : []
|
|
169
|
+
const found = [...fromRef, ...candidates(ctx)].find((p) => (p.version === 'v3' || p.version === 'v4') && p.positionManager && same(p.positionManager, pm))
|
|
170
|
+
if (!found) {
|
|
171
|
+
throw new Error(`Unknown position manager ${pm} on chain ${ctx.chainId}; pass its LpProtocol in options.protocols`)
|
|
172
|
+
}
|
|
173
|
+
return found
|
|
174
|
+
}
|
|
175
|
+
if (!ref.protocol) {
|
|
176
|
+
throw new Error('position needs positionManager or protocol')
|
|
177
|
+
}
|
|
178
|
+
const found = protocolById(ctx, ref.protocol)
|
|
179
|
+
if (found.version === 'v2') {
|
|
180
|
+
throw new Error(`${found.id} is a V2 protocol; V2 positions are identified by pool`)
|
|
181
|
+
}
|
|
182
|
+
return found
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 仓位是哪个版本:LpPosition 自带;PositionRef 有 tokenId 的看协议,否则是 V2 */
|
|
186
|
+
export function positionVersion(ctx: Ctx, ref: PositionRef | LpPosition): LpVersion {
|
|
187
|
+
if ('version' in ref && (ref.version === 'v2' || ref.version === 'v3' || ref.version === 'v4')) {
|
|
188
|
+
return ref.version
|
|
189
|
+
}
|
|
190
|
+
if (!('tokenId' in ref) || ref.tokenId === undefined) {
|
|
191
|
+
if (!ref.pool) {
|
|
192
|
+
throw new Error('position needs pool (V2) or tokenId (V3 / V4)')
|
|
193
|
+
}
|
|
194
|
+
// 传了 V3 / V4 的协议却没有 tokenId:说清楚,不要当成 V2 的池子去读
|
|
195
|
+
if (ref.protocol && (typeof ref.protocol === 'string' ? protocolById(ctx, ref.protocol) : ref.protocol).version !== 'v2') {
|
|
196
|
+
throw new Error('position.tokenId is required for V3 / V4 positions')
|
|
197
|
+
}
|
|
198
|
+
return 'v2'
|
|
199
|
+
}
|
|
200
|
+
return concentratedProtocol(ctx, ref).version
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function tokenIdOf(ref: PositionRef | LpPosition): bigint {
|
|
204
|
+
if (!('tokenId' in ref) || ref.tokenId === undefined) {
|
|
205
|
+
throw new Error('position.tokenId is required')
|
|
206
|
+
}
|
|
207
|
+
if (typeof ref.tokenId === 'number' && !Number.isSafeInteger(ref.tokenId)) {
|
|
208
|
+
throw new RangeError(`tokenId ${ref.tokenId} is not a safe integer; pass it as a string or bigint`)
|
|
209
|
+
}
|
|
210
|
+
return getBigInt(ref.tokenId)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 要求先把额度归零才能改成另一个非零值的代币(chainId:小写地址)。只对它们先 approve 0:
|
|
215
|
+
* 有的代币(如 Ethereum 上的 BNB)反过来不允许 approve 0
|
|
216
|
+
*/
|
|
217
|
+
const RESET_FIRST = new Set([
|
|
218
|
+
'1:0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT
|
|
219
|
+
'1:0xdd974d5c2e2928dea5f71b9825b8b646686bd200', // KNC(旧版)
|
|
220
|
+
])
|
|
221
|
+
|
|
222
|
+
/** ERC20 授权:额度不够时 approve(USDT 等已有非零额度时先 approve 0)。amount 为 0 的不需要 */
|
|
223
|
+
export async function approvals(ctx: Ctx, items: ReadonlyArray<{ token: string; spender: string; amount: bigint }>): Promise<LiquidityTx[]> {
|
|
224
|
+
const needed = items.filter((i) => i.amount > 0n)
|
|
225
|
+
if (!needed.length) {
|
|
226
|
+
return []
|
|
227
|
+
}
|
|
228
|
+
const current = await ctx.provider.all(needed.map((i) => ctx.provider.contract(i.token, erc20Interface).allowance(ctx.owner, i.spender)))
|
|
229
|
+
return needed.flatMap((item, k): LiquidityTx[] => {
|
|
230
|
+
const allowance = (current as bigint[])[k] as bigint
|
|
231
|
+
if (allowance >= item.amount) {
|
|
232
|
+
return []
|
|
233
|
+
}
|
|
234
|
+
const amount = ctx.options.approve === 'max' ? MaxUint256 : item.amount
|
|
235
|
+
const tx = (value: bigint): LiquidityTx => ({
|
|
236
|
+
kind: 'approve',
|
|
237
|
+
to: out(ctx, item.token),
|
|
238
|
+
data: erc20Interface.encodeFunctionData('approve', [item.spender, value]),
|
|
239
|
+
value: '0',
|
|
240
|
+
token: out(ctx, item.token),
|
|
241
|
+
spender: out(ctx, item.spender),
|
|
242
|
+
amount: value.toString(),
|
|
243
|
+
})
|
|
244
|
+
return allowance > 0n && RESET_FIRST.has(`${ctx.chainId}:${item.token.toLowerCase()}`) ? [tx(0n), tx(amount)] : [tx(amount)]
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** V4:代币先授权给 Permit2,再在 Permit2 里授权给仓位合约(额度不够或已过期时) */
|
|
249
|
+
export async function permit2Approvals(ctx: Ctx, spender: string, items: ReadonlyArray<{ token: string; amount: bigint }>): Promise<LiquidityTx[]> {
|
|
250
|
+
const needed = items.filter((i) => i.amount > 0n)
|
|
251
|
+
if (!needed.length) {
|
|
252
|
+
return []
|
|
253
|
+
}
|
|
254
|
+
const erc20 = await approvals(
|
|
255
|
+
ctx,
|
|
256
|
+
needed.map((i) => ({ token: i.token, spender: PERMIT2_ADDRESS, amount: i.amount })),
|
|
257
|
+
)
|
|
258
|
+
const permits = (await ctx.provider.all(needed.map((i) => ctx.provider.contract(PERMIT2_ADDRESS, permit2Interface).allowance(ctx.owner, i.token, spender)))) as Array<{ 0: bigint; 1: bigint }>
|
|
259
|
+
const now = Math.floor(Date.now() / 1000)
|
|
260
|
+
const permit2 = needed.flatMap((item, k): LiquidityTx[] => {
|
|
261
|
+
const [amount, expiration] = [permits[k]?.[0] ?? 0n, Number(permits[k]?.[1] ?? 0n)]
|
|
262
|
+
// 额度够、并且在交易截止时间之后才过期:不用再授权
|
|
263
|
+
if (amount >= item.amount && expiration >= ctx.deadline) {
|
|
264
|
+
return []
|
|
265
|
+
}
|
|
266
|
+
// 过期重新授权时保留原来更大的额度(如之前授权过无限额度)
|
|
267
|
+
const value = ctx.options.approve === 'max' ? MAX_UINT160 : amount > item.amount ? amount : item.amount
|
|
268
|
+
return [
|
|
269
|
+
{
|
|
270
|
+
kind: 'permit2',
|
|
271
|
+
to: PERMIT2_ADDRESS,
|
|
272
|
+
data: permit2Interface.encodeFunctionData('approve', [item.token, spender, value, Math.max(now, ctx.deadline) + PERMIT2_EXPIRATION]),
|
|
273
|
+
value: '0',
|
|
274
|
+
token: item.token,
|
|
275
|
+
spender,
|
|
276
|
+
amount: value.toString(),
|
|
277
|
+
},
|
|
278
|
+
]
|
|
279
|
+
})
|
|
280
|
+
return [...erc20, ...permit2]
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 计划里的价格字段(token1 / token0,按精度换算) */
|
|
284
|
+
export function priceFields(token0: LpToken, token1: LpToken, sqrtPriceX96: bigint | null, tickLower: number, tickUpper: number) {
|
|
285
|
+
if (token0.decimals === null || token1.decimals === null) {
|
|
286
|
+
return { price: null, priceLower: null, priceUpper: null }
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
price: sqrtPriceX96 ? sqrtPriceToPrice(sqrtPriceX96, token0.decimals, token1.decimals) : null,
|
|
290
|
+
priceLower: tickToPrice(tickLower, token0.decimals, token1.decimals),
|
|
291
|
+
priceUpper: tickToPrice(tickUpper, token0.decimals, token1.decimals),
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
type PlanInput = Omit<LiquidityPlan, 'chainId' | 'owner' | 'recipient' | 'deadline' | 'amount0Formatted' | 'amount1Formatted' | 'fees0Formatted' | 'fees1Formatted' | 'pool'> & {
|
|
296
|
+
pool: string | null
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** 补上公共字段、格式化数量 */
|
|
300
|
+
export function makePlan(ctx: Ctx, input: PlanInput): LiquidityPlan {
|
|
301
|
+
const { token0, token1 } = input
|
|
302
|
+
return {
|
|
303
|
+
...input,
|
|
304
|
+
chainId: ctx.chainId,
|
|
305
|
+
pool: input.pool === null || input.version === 'v4' ? input.pool : out(ctx, input.pool),
|
|
306
|
+
owner: out(ctx, ctx.owner),
|
|
307
|
+
recipient: out(ctx, ctx.recipient),
|
|
308
|
+
amount0Formatted: fmt(BigInt(input.amount0), token0),
|
|
309
|
+
amount1Formatted: fmt(BigInt(input.amount1), token1),
|
|
310
|
+
fees0Formatted: fmt(BigInt(input.fees0), token0),
|
|
311
|
+
fees1Formatted: fmt(BigInt(input.fees1), token1),
|
|
312
|
+
deadline: ctx.deadline,
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { LpToken } from '../lp.js'
|
|
2
|
+
import {
|
|
3
|
+
MAX_TICK,
|
|
4
|
+
MIN_TICK,
|
|
5
|
+
clampedTickAtSqrtRatio,
|
|
6
|
+
getLiquidityForAmount0,
|
|
7
|
+
getLiquidityForAmount1,
|
|
8
|
+
getLiquidityForAmounts,
|
|
9
|
+
getMintAmounts,
|
|
10
|
+
getSqrtRatioAtTick,
|
|
11
|
+
nearestUsableTick,
|
|
12
|
+
parseDecimal,
|
|
13
|
+
ratioToSqrtPriceX96,
|
|
14
|
+
sqrtPriceBounds,
|
|
15
|
+
toUint128,
|
|
16
|
+
} from '../lpMath.js'
|
|
17
|
+
import type { NewPositionParams } from './types.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 价格(tokenB / tokenA,按精度换算)→ 池子的 sqrtPriceX96(token1 / token0)。aIs0:tokenA 是不是 token0
|
|
21
|
+
*/
|
|
22
|
+
export function sqrtPriceOf(price: string | number, aIs0: boolean, token0: LpToken, token1: LpToken, name: string): bigint {
|
|
23
|
+
if (token0.decimals === null || token1.decimals === null) {
|
|
24
|
+
throw new Error(`Cannot read token decimals to convert ${name}; pass ticks instead`)
|
|
25
|
+
}
|
|
26
|
+
const { num, den } = parseDecimal(price)
|
|
27
|
+
if (num === 0n) {
|
|
28
|
+
throw new RangeError(`${name} must be greater than 0`)
|
|
29
|
+
}
|
|
30
|
+
// tokenA 是 token1 时取倒数
|
|
31
|
+
return aIs0 ? ratioToSqrtPriceX96(num, den, token0.decimals, token1.decimals) : ratioToSqrtPriceX96(den, num, token0.decimals, token1.decimals)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 新建仓位的 tick 区间:fullRange / tickLower + tickUpper / priceLower + priceUpper 三选一 */
|
|
35
|
+
export function resolveRange(params: NewPositionParams, aIs0: boolean, token0: LpToken, token1: LpToken, tickSpacing: number): [number, number] {
|
|
36
|
+
const byTicks = params.tickLower !== undefined || params.tickUpper !== undefined
|
|
37
|
+
const byPrices = params.priceLower !== undefined || params.priceUpper !== undefined
|
|
38
|
+
if (Number(!!params.fullRange) + Number(byTicks) + Number(byPrices) !== 1) {
|
|
39
|
+
throw new Error('Pass exactly one of fullRange, tickLower + tickUpper, or priceLower + priceUpper')
|
|
40
|
+
}
|
|
41
|
+
let lower: number
|
|
42
|
+
let upper: number
|
|
43
|
+
if (params.fullRange) {
|
|
44
|
+
lower = Math.ceil(MIN_TICK / tickSpacing) * tickSpacing
|
|
45
|
+
upper = Math.floor(MAX_TICK / tickSpacing) * tickSpacing
|
|
46
|
+
} else if (byTicks) {
|
|
47
|
+
if (params.tickLower === undefined || params.tickUpper === undefined) {
|
|
48
|
+
throw new Error('Pass both tickLower and tickUpper')
|
|
49
|
+
}
|
|
50
|
+
lower = params.tickLower
|
|
51
|
+
upper = params.tickUpper
|
|
52
|
+
for (const tick of [lower, upper]) {
|
|
53
|
+
if (!Number.isInteger(tick) || tick < MIN_TICK || tick > MAX_TICK || tick % tickSpacing !== 0) {
|
|
54
|
+
throw new RangeError(`tick ${tick} must be an integer multiple of tickSpacing ${tickSpacing} within [${MIN_TICK}, ${MAX_TICK}]`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
if (params.priceLower === undefined || params.priceUpper === undefined) {
|
|
59
|
+
throw new Error('Pass both priceLower and priceUpper')
|
|
60
|
+
}
|
|
61
|
+
// tokenA 是 token1 时价格取倒数,区间两端也随之对调;下面按大小排序
|
|
62
|
+
const ticks = [params.priceLower, params.priceUpper].map((price, i) => {
|
|
63
|
+
return nearestUsableTick(clampedTickAtSqrtRatio(sqrtPriceOf(price, aIs0, token0, token1, i ? 'priceUpper' : 'priceLower')), tickSpacing)
|
|
64
|
+
}) as [number, number]
|
|
65
|
+
;[lower, upper] = ticks[0] <= ticks[1] ? ticks : [ticks[1], ticks[0]]
|
|
66
|
+
}
|
|
67
|
+
if (lower >= upper) {
|
|
68
|
+
throw new RangeError(`The price range is too narrow: tickLower ${lower} must be below tickUpper ${upper} (tickSpacing ${tickSpacing})`)
|
|
69
|
+
}
|
|
70
|
+
return [lower, upper]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 按输入的数量算添加的流动性:两边都给时取较少的一边;只给一边时另一边按当前价格算。
|
|
75
|
+
* 价格在区间外时只需要一种代币,给错了另一种会报错
|
|
76
|
+
*/
|
|
77
|
+
export function liquidityFor(sqrtPrice: bigint, tickLower: number, tickUpper: number, amount0: bigint | undefined, amount1: bigint | undefined, token0: LpToken, token1: LpToken): bigint {
|
|
78
|
+
const sqrtA = getSqrtRatioAtTick(tickLower)
|
|
79
|
+
const sqrtB = getSqrtRatioAtTick(tickUpper)
|
|
80
|
+
const name = (t: LpToken) => t.symbol ?? t.address
|
|
81
|
+
if (amount0 === undefined && amount1 === undefined) {
|
|
82
|
+
throw new Error('Pass the amount of at least one token')
|
|
83
|
+
}
|
|
84
|
+
// 价格在区间外时只需要一种代币:给的是另一种(或需要的那种是 0)时说明要给哪种
|
|
85
|
+
if (sqrtPrice >= sqrtB && !amount1) {
|
|
86
|
+
throw new Error(`The current price is above the range: the position takes only ${name(token1)} (token1); pass its amount instead`)
|
|
87
|
+
}
|
|
88
|
+
if (sqrtPrice <= sqrtA && !amount0) {
|
|
89
|
+
throw new Error(`The current price is below the range: the position takes only ${name(token0)} (token0); pass its amount instead`)
|
|
90
|
+
}
|
|
91
|
+
let liquidity: bigint
|
|
92
|
+
if (amount0 !== undefined && amount1 !== undefined) {
|
|
93
|
+
liquidity = getLiquidityForAmounts(sqrtPrice, sqrtA, sqrtB, amount0, amount1)
|
|
94
|
+
} else if (amount0 !== undefined) {
|
|
95
|
+
liquidity = getLiquidityForAmount0(sqrtPrice > sqrtA ? sqrtPrice : sqrtA, sqrtB, amount0)
|
|
96
|
+
} else {
|
|
97
|
+
liquidity = getLiquidityForAmount1(sqrtA, sqrtPrice < sqrtB ? sqrtPrice : sqrtB, amount1 as bigint)
|
|
98
|
+
}
|
|
99
|
+
if (liquidity === 0n) {
|
|
100
|
+
throw new Error('Amount too small: the liquidity rounds to 0')
|
|
101
|
+
}
|
|
102
|
+
return toUint128(liquidity)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* V3:按传给仓位合约的数量(desired),价格在滑点范围内变动时至少会用掉多少(amountMin)。
|
|
107
|
+
* 合约按当时的价格重新算流动性:token0 在价格最高时用得最少,token1 在最低时;取三个价格下的最小值
|
|
108
|
+
*/
|
|
109
|
+
export function v3MinAmounts(sqrtPrice: bigint, tickLower: number, tickUpper: number, desired0: bigint, desired1: bigint, slippage: number): [bigint, bigint] {
|
|
110
|
+
const sqrtA = getSqrtRatioAtTick(tickLower)
|
|
111
|
+
const sqrtB = getSqrtRatioAtTick(tickUpper)
|
|
112
|
+
const [lower, upper] = sqrtPriceBounds(sqrtPrice, slippage)
|
|
113
|
+
let min0: bigint | null = null
|
|
114
|
+
let min1: bigint | null = null
|
|
115
|
+
for (const s of [lower, sqrtPrice, upper]) {
|
|
116
|
+
const [a0, a1] = getMintAmounts(s, sqrtA, sqrtB, getLiquidityForAmounts(s, sqrtA, sqrtB, desired0, desired1))
|
|
117
|
+
min0 = min0 === null || a0 < min0 ? a0 : min0
|
|
118
|
+
min1 = min1 === null || a1 < min1 ? a1 : min1
|
|
119
|
+
}
|
|
120
|
+
return [min0 as bigint, min1 as bigint]
|
|
121
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Interface } from 'ethers'
|
|
2
|
+
|
|
3
|
+
import type { Provider } from '../provider.js'
|
|
4
|
+
import { batchCall, tronModule } from '../write/batch.js'
|
|
5
|
+
import { WriteRpc } from '../write/rpc.js'
|
|
6
|
+
import { resolveSigner, type SignerInput } from '../write/signer.js'
|
|
7
|
+
import { ZERO_ADDRESS, createCtx, evm, positionVersion, protocolById } from './common.js'
|
|
8
|
+
import type {
|
|
9
|
+
AddLiquidityParams,
|
|
10
|
+
CollectFeesParams,
|
|
11
|
+
LiquidityOptions,
|
|
12
|
+
LiquidityPlan,
|
|
13
|
+
LiquidityResult,
|
|
14
|
+
LiquiditySendOptions,
|
|
15
|
+
LiquidityTx,
|
|
16
|
+
LiquidityTxResult,
|
|
17
|
+
RemoveLiquidityParams,
|
|
18
|
+
} from './types.js'
|
|
19
|
+
import { v2Add, v2Remove } from './v2.js'
|
|
20
|
+
import { v3Add, v3Remove } from './v3.js'
|
|
21
|
+
import { v4Add, v4Remove } from './v4.js'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 添加流动性的计划(不签名、不发送):新建仓位,或往已有仓位追加。返回预计的数量和要按顺序发送的交易(授权在前)。
|
|
25
|
+
* 只做读取,也可以当作报价:输入一边的数量,看另一边要多少、能拿到多少流动性
|
|
26
|
+
*
|
|
27
|
+
* ```ts
|
|
28
|
+
* // 新建 V3 仓位:价格区间按 tokenB / tokenA,给一边的数量
|
|
29
|
+
* const plan = await buildAddLiquidity(multi, owner, { protocol: 'pancakeswap-v3', tokenA: WBNB, tokenB: USDT, fee: 500, priceLower: 550, priceUpper: 650, amountAFormatted: '1' })
|
|
30
|
+
* plan.amount1Formatted // 需要的 USDT
|
|
31
|
+
* plan.transactions // [approve…, add],可以交给钱包逐笔发送
|
|
32
|
+
* // 往已有仓位追加(position 可以直接用 getLpPositions 的结果)
|
|
33
|
+
* await buildAddLiquidity(multi, owner, { position, amount0Formatted: '0.5' })
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export async function buildAddLiquidity(provider: Provider, owner: string, params: AddLiquidityParams, options: LiquidityOptions = {}): Promise<LiquidityPlan> {
|
|
37
|
+
const ctx = await createCtx(provider, owner, options)
|
|
38
|
+
if ('position' in params) {
|
|
39
|
+
const version = positionVersion(ctx, params.position)
|
|
40
|
+
return version === 'v2' ? v2Add(ctx, params) : version === 'v3' ? v3Add(ctx, params) : v4Add(ctx, params)
|
|
41
|
+
}
|
|
42
|
+
const protocol = protocolById(ctx, params.protocol)
|
|
43
|
+
const resolved = { ...params, protocol }
|
|
44
|
+
return protocol.version === 'v2' ? v2Add(ctx, resolved) : protocol.version === 'v3' ? v3Add(ctx, resolved) : v4Add(ctx, resolved)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 移除流动性的计划(不签名、不发送):按比例或指定数量移除,V3 / V4 同时领取全部未领取的手续费。
|
|
49
|
+
*
|
|
50
|
+
* ```ts
|
|
51
|
+
* await buildRemoveLiquidity(multi, owner, { position, percent: 50 })
|
|
52
|
+
* await buildRemoveLiquidity(multi, owner, { position, burn: true }) // 全部移除并销毁 NFT
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export async function buildRemoveLiquidity(provider: Provider, owner: string, params: RemoveLiquidityParams, options: LiquidityOptions = {}): Promise<LiquidityPlan> {
|
|
56
|
+
const ctx = await createCtx(provider, owner, options)
|
|
57
|
+
const version = positionVersion(ctx, params.position)
|
|
58
|
+
return version === 'v2' ? v2Remove(ctx, params) : version === 'v3' ? v3Remove(ctx, params) : v4Remove(ctx, params)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 只领取 V3 / V4 仓位的手续费(不动流动性)的计划 */
|
|
62
|
+
export async function buildCollectFees(provider: Provider, owner: string, params: CollectFeesParams, options: LiquidityOptions = {}): Promise<LiquidityPlan> {
|
|
63
|
+
const ctx = await createCtx(provider, owner, options)
|
|
64
|
+
const version = positionVersion(ctx, params.position)
|
|
65
|
+
if (version === 'v2') {
|
|
66
|
+
throw new Error('V2 has no separate fees: they are part of the LP tokens, remove liquidity to get them')
|
|
67
|
+
}
|
|
68
|
+
return version === 'v3' ? v3Remove(ctx, { position: params.position }, true) : v4Remove(ctx, { position: params.position }, true)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 添加流动性:构建计划(同 buildAddLiquidity)后用 from 签名发送,授权不够时先发授权交易。
|
|
73
|
+
* from 与 /write 的 batchCall 相同:私钥、ethers Signer、浏览器钱包(Tron 上也可以是 tronWeb / adapter)
|
|
74
|
+
*
|
|
75
|
+
* ```ts
|
|
76
|
+
* const { status, tokenId, plan } = await addLiquidity(multi, privateKey, { protocol: 'uniswap-v3', tokenA: NATIVE_TOKEN, tokenB: USDC, fee: 500, fullRange: true, amountAFormatted: '0.1' })
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export async function addLiquidity(provider: Provider, from: SignerInput, params: AddLiquidityParams, options: LiquiditySendOptions = {}): Promise<LiquidityResult> {
|
|
80
|
+
const plan = await buildAddLiquidity(provider, await signerAddress(provider, from), params, options)
|
|
81
|
+
if (options.checkBalance ?? true) {
|
|
82
|
+
await checkBalance(provider, plan)
|
|
83
|
+
}
|
|
84
|
+
return execute(provider, from, plan, options)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 移除流动性:构建计划(同 buildRemoveLiquidity)后用 from 签名发送 */
|
|
88
|
+
export async function removeLiquidity(provider: Provider, from: SignerInput, params: RemoveLiquidityParams, options: LiquiditySendOptions = {}): Promise<LiquidityResult> {
|
|
89
|
+
const plan = await buildRemoveLiquidity(provider, await signerAddress(provider, from), params, options)
|
|
90
|
+
return execute(provider, from, plan, options)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 领取 V3 / V4 仓位的手续费 */
|
|
94
|
+
export async function collectFees(provider: Provider, from: SignerInput, params: CollectFeesParams, options: LiquiditySendOptions = {}): Promise<LiquidityResult> {
|
|
95
|
+
const plan = await buildCollectFees(provider, await signerAddress(provider, from), params, options)
|
|
96
|
+
return execute(provider, from, plan, options)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 签名器的地址(Tron 上为 T 地址),不发交易 */
|
|
100
|
+
async function signerAddress(provider: Provider, from: SignerInput): Promise<string> {
|
|
101
|
+
const tron = await tronModule(provider)
|
|
102
|
+
return tron ? tron.tronSignerAddress(from) : (await resolveSigner(from)).address
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 添加前检查余额:代币按预计数量,主币按附带的 value(不含手续费) */
|
|
106
|
+
async function checkBalance(provider: Provider, plan: LiquidityPlan): Promise<void> {
|
|
107
|
+
const needs: Array<[string, bigint]> = []
|
|
108
|
+
// V4 追加时先自动领取手续费、与存入相抵:只需要差额
|
|
109
|
+
const offset = (amount: string, fees: string) => {
|
|
110
|
+
const need = BigInt(amount) - (plan.version === 'v4' && plan.tokenId !== null ? BigInt(fees) : 0n)
|
|
111
|
+
return need > 0n ? need : 0n
|
|
112
|
+
}
|
|
113
|
+
if (plan.native !== 'token0') needs.push([plan.token0.address, offset(plan.amount0, plan.fees0)])
|
|
114
|
+
if (plan.native !== 'token1') needs.push([plan.token1.address, offset(plan.amount1, plan.fees1)])
|
|
115
|
+
if (BigInt(plan.value) > 0n) needs.push([provider.nativeTokens[0] ?? ZERO_ADDRESS, BigInt(plan.value)])
|
|
116
|
+
const list = needs.filter(([, amount]) => amount > 0n)
|
|
117
|
+
if (!list.length) {
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
const balances = await provider.balances(plan.owner, list.map(([token]) => token))
|
|
121
|
+
list.forEach(([token, amount], i) => {
|
|
122
|
+
const b = balances[i]
|
|
123
|
+
// 读不到余额的不拦(预估 gas 时也会失败)
|
|
124
|
+
if (b?.success && BigInt(b.balance) < amount) {
|
|
125
|
+
throw new Error(`Insufficient balance of ${token}: need ${amount}, have ${b.balance}`)
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const transferTopic = new Interface(['event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)']).getEvent('Transfer')?.topicHash as string
|
|
131
|
+
|
|
132
|
+
async function execute(provider: Provider, from: SignerInput, plan: LiquidityPlan, options: LiquiditySendOptions): Promise<LiquidityResult> {
|
|
133
|
+
const { transactions } = plan
|
|
134
|
+
const results = (
|
|
135
|
+
await batchCall(
|
|
136
|
+
provider,
|
|
137
|
+
transactions.map((tx) => ({ from, to: tx.to, data: tx.data, value: BigInt(tx.value) })),
|
|
138
|
+
{
|
|
139
|
+
...options,
|
|
140
|
+
mode: 'tx',
|
|
141
|
+
// 授权失败时不发主交易
|
|
142
|
+
continueOnError: false,
|
|
143
|
+
onUpdate: options.onUpdate ? (r) => options.onUpdate?.({ ...r, kind: (transactions[r.index] as LiquidityTx).kind }) : undefined,
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
).map((r, i): LiquidityTxResult => ({ ...r, kind: (transactions[i] as LiquidityTx).kind }))
|
|
147
|
+
const main = results[results.length - 1] as LiquidityTxResult
|
|
148
|
+
let tokenId: string | null = plan.tokenId
|
|
149
|
+
if (plan.action === 'add' && plan.tokenId === null && plan.version !== 'v2') {
|
|
150
|
+
tokenId = main.status === 'confirmed' && main.hash ? await mintedTokenId(provider, plan, main.hash) : null
|
|
151
|
+
}
|
|
152
|
+
return { plan, results, status: main.status, hash: main.hash, tokenId }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 新建仓位的 tokenId:主交易回执里仓位合约从零地址转出的 NFT(Tron 上不读,为 null) */
|
|
156
|
+
async function mintedTokenId(provider: Provider, plan: LiquidityPlan, hash: string): Promise<string | null> {
|
|
157
|
+
try {
|
|
158
|
+
if (await tronModule(provider)) {
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
const receipt = await (await WriteRpc.from(provider)).receipt(hash)
|
|
162
|
+
const pm = evm((plan.transactions[plan.transactions.length - 1] as { to: string }).to)
|
|
163
|
+
const log = receipt?.logs.find(
|
|
164
|
+
(l) => l.topics.length === 4 && l.topics[0] === transferTopic && BigInt(l.topics[1] as string) === 0n && evm(l.address) === pm,
|
|
165
|
+
)
|
|
166
|
+
return log ? BigInt(log.topics[3] as string).toString() : null
|
|
167
|
+
} catch {
|
|
168
|
+
return null // 读回执失败不影响结果(交易已确认)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|