lpsignal 0.9.0 → 0.10.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 CHANGED
@@ -104,6 +104,60 @@ app.post('/lpsignal', express.raw({ type: 'application/json' }), (req, res) => {
104
104
  Pass the raw body (a `Buffer` or string), never re-serialised JSON. Deliveries older than 5 minutes are rejected
105
105
  (`toleranceSec`); every retry is signed afresh.
106
106
 
107
+ ## Adding and removing liquidity
108
+
109
+ `lpsignal/liquidity` builds the transactions to add liquidity to a pool (in the range you choose) and to remove it,
110
+ on the pools' official position managers — Uniswap v3, PancakeSwap v3, Aerodrome and Velodrome Slipstream. You sign
111
+ and send them with your own [viem](https://viem.sh) wallet: your keys never reach the SDK, the position is always minted
112
+ to and collected by your own address, and there is no LPSignal contract or fee in between. Uniswap v4 pools are not
113
+ supported here (use the Uniswap app). Install viem next to the SDK: `npm i lpsignal viem`.
114
+
115
+ ```js
116
+ import { LPSignal } from 'lpsignal';
117
+ import { planAddLiquidity, planRemoveLiquidity, positions, sendPlan } from 'lpsignal/liquidity';
118
+ import { createPublicClient, createWalletClient, http, parseEther } from 'viem';
119
+ import { privateKeyToAccount } from 'viem/accounts';
120
+ import { base } from 'viem/chains';
121
+
122
+ const account = privateKeyToAccount(process.env.PRIVATE_KEY);
123
+ const publicClient = createPublicClient({ chain: base, transport: http() });
124
+ const wallet = createWalletClient({ account, chain: base, transport: http() });
125
+
126
+ // the pool's tokens, decimals, fee and tick spacing
127
+ const { pool } = await new LPSignal().pool('base', '0x6c561b446416e1a00e8e93e221854d6ea4171372');
128
+ // ±5% around the current price, 1 WETH in, paid in ETH; the USDC side is computed
129
+ const plan = await planAddLiquidity(publicClient, pool, { owner: account.address, amount0: parseEther('1'), rangeBp: 500, nativeSide: 0 });
130
+ await sendPlan(wallet, publicClient, [...plan.approvals, plan.mint]); // exact approvals, then the mint
131
+
132
+ // later: your positions, then take half of one out (principal + fees, to you)
133
+ const [pos] = (await positions(publicClient, 'base', account.address)).filter((p) => p.pool === pool.address);
134
+ const half = await planRemoveLiquidity(publicClient, pos, { owner: account.address, shareBps: 5000 });
135
+ // finalized: wait until that block is final before taking more from the same position (a reorg could otherwise drop
136
+ // this removal while the next one reads the old liquidity, and both land)
137
+ const [{ blockNumber }] = await sendPlan(wallet, publicClient, [half.call], { finalized: true });
138
+ // the rest: read at a block no older than that removal
139
+ const rest = await planRemoveLiquidity(publicClient, pos, { owner: account.address, shareBps: 10000, minBlock: blockNumber });
140
+ await sendPlan(wallet, publicClient, [rest.call]);
141
+ ```
142
+
143
+ - Minimum amounts follow the Uniswap SDK's rule for a price move of up to `slippageBps` (default 0.5%); transactions
144
+ expire after `deadlineS` (default 20 minutes).
145
+ - Every planned call is bound to its chain and owner: `sendPlan` refuses to send it from another account or chain.
146
+ - `sendPlan` waits for each receipt. If one is not seen in time it throws `TxPending` with the hash: **do not send the
147
+ same mint or partial removal again until you know what became of it** — a second one would also go through. A
148
+ transaction cancelled or replaced in the wallet throws `TxReplaced` and stops the plan (a speed-up is fine). A send
149
+ that fails without a hash throws `TxUnknown` with the account and the nonce it was sent with: check whether that nonce
150
+ was used first (with a nonce manager or a wallet that picks nonces itself, check its history). Plans of one account
151
+ are sent one after another within a process; do not send from the same account elsewhere at the same time. After a
152
+ `TxUnknown`, `TxPending` or `TxReplaced`, every further send from that account on that chain throws `AccountBlocked` until you have
153
+ checked the transaction and call `unblock(chainId, account)`.
154
+ - Minimum amounts are what the position manager would take at the edges of the `slippage` band. With a range narrower
155
+ than that band (e.g. ±0.05% on a stable pair at 0.5% slippage) both minimums can be 0: the mint then has no on-chain
156
+ price bound, but a price pushed outside your range only makes the deposit single-sided (a mint never trades), and
157
+ coming back it converts at prices inside your range — the loss is bounded by the range's width.
158
+ - Positions staked in an Aerodrome / Velodrome gauge belong to the gauge and are not listed by `positions`.
159
+ - Not financial advice: a range that paid well can lose money if the price leaves it.
160
+
107
161
  ## License
108
162
 
109
163
  MIT
package/README.zh.md CHANGED
@@ -71,6 +71,23 @@ await lps.createRule({ kind: 'depeg', name: 'early depeg', minDeviation: 0.003 }
71
71
  await lps.setSubscriptions(['net_apr', 'burst', 'depeg']); // 推送哪些类型(规则命中总会推送)
72
72
  ```
73
73
 
74
+ ## 添加和移除流动性
75
+
76
+ `lpsignal/liquidity` 负责构造交易:在你选的价格区间内添加流动性,以及移除流动性。交易发到池子的官方仓位合约(Uniswap v3、PancakeSwap v3、Aerodrome 和 Velodrome Slipstream)。签名和发送都用你自己的 [viem](https://viem.sh) 钱包:私钥不经过 SDK;仓位只会创建到你自己的地址,资金也只领回到你自己的地址;中间没有 LPSignal 的合约,也不收费。这里不支持 Uniswap v4 池子(请用 Uniswap 官方应用)。需要和 SDK 一起安装 viem:`npm i lpsignal viem`。
77
+
78
+ 用法见英文 README 的示例:`planAddLiquidity` → `sendPlan`,`positions` → `planRemoveLiquidity` → `sendPlan`。
79
+
80
+ - 最低成交量按 Uniswap SDK 的规则计算,允许的价格变动由 `slippageBps` 指定(默认 0.5%);交易在 `deadlineS` 之后失效(默认 20 分钟)。
81
+ - `sendPlan` 会等每一笔的回执。如果规定时间内没等到,会抛出带交易哈希的 `TxPending`:**在弄清楚这笔交易的结果之前,不要重发同一笔创建仓位或部分移除的交易**,否则第二笔也会成交。
82
+ - 每笔计划好的交易都绑定了所属的链和账户:从别的账户或别的链发送,`sendPlan` 会拒绝。在钱包里被取消或替换成别的交易时,会抛出 `TxReplaced` 并停止整个计划(只是加速则没关系)。
83
+ - 发送时出错且拿不到交易哈希(节点可能已经收下了这笔交易)会抛出 `TxUnknown`,带上账户和 nonce:请先确认这个 nonce 有没有被用掉,再决定是否重发。
84
+ - 出现 `TxUnknown`、`TxPending` 或 `TxReplaced` 后,这个账户在这条链上的后续发送都会抛出 `AccountBlocked`,直到你核实那笔交易后调用 `unblock(chainId, account)`。
85
+ - 对同一个仓位做下一次部分移除前,给 `sendPlan` 传 `{ finalized: true }`,等那个区块最终确认后再继续,避免链重组导致多取。
86
+ - 对同一个仓位做下一次移除时,把上一次 `sendPlan` 返回的区块号作为 `minBlock` 传入,这样 SDK 只会在不旧于那个区块的数据上计算,不会因为 RPC 节点落后而多取。
87
+ - 最低成交量是价格在滑点范围两端时、仓位合约实际会收取的数量。如果区间比滑点范围还窄(例如稳定币池 ±0.05% 区间配 0.5% 滑点),两个最低值都可能是 0,这时交易在链上没有价格限制。但价格被推出你的区间时,添加只会变成存入单一代币(添加本身不做兑换);价格回来时,转换只发生在你的区间内,所以损失上限是区间的宽度。
88
+ - 质押在 Aerodrome / Velodrome gauge 里的仓位属于 gauge,`positions` 不会列出。
89
+ - 不构成投资建议:过去收益好的区间,价格离开后也可能亏损。
90
+
74
91
  ## 开发
75
92
 
76
93
  ```bash
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Concentrated-liquidity math and the official position-manager calls (Uniswap v3 / PancakeSwap v3
3
+ * NonfungiblePositionManager, Aerodrome / Velodrome Slipstream) — the same code the lpsignal.app web app runs, kept in
4
+ * step with it. Pure: nothing here signs or sends; recipients are always the caller's own address.
5
+ *
6
+ * Math is the pools' own, in integers: TickMath.getSqrtRatioAtTick, LiquidityAmounts, SqrtPriceMath (rounding as the
7
+ * contracts do), and the Uniswap SDK's slippage rule for the minimum amounts.
8
+ */
9
+ import { type Address, type Hex } from 'viem';
10
+ export type LpDex = 'uniswap_v3' | 'pancake_v3' | 'aerodrome_cl' | 'velodrome_cl';
11
+ /** position managers per chain and dex — the ones whose factory created the pools we track (src/chains.ts `npms`) */
12
+ export declare const NPM: Record<string, Partial<Record<LpDex, Address>>>;
13
+ export declare const CHAIN_ID: Record<string, number>;
14
+ /** the wrapped native coin the position managers wrap msg.value into (their WETH9()) */
15
+ export declare const WRAPPED: Record<string, Address>;
16
+ export declare const EXPLORER: Record<string, string>;
17
+ export declare const NATIVE_SYMBOL: Record<string, string>;
18
+ /** USDT on Ethereum refuses to change a non-zero allowance to another non-zero one: reset it to 0 first */
19
+ export declare const ZERO_FIRST: Record<string, Address[]>;
20
+ export declare const isSlipstream: (dex: string) => boolean;
21
+ export declare function npmOf(chain: string, dex: string): Address | null;
22
+ export declare const MIN_TICK = -887272;
23
+ export declare const MAX_TICK = 887272;
24
+ export declare const MIN_SQRT_RATIO = 4295128739n;
25
+ export declare const MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342n;
26
+ export declare function sqrtRatioAtTick(tick: number): bigint;
27
+ /** the full range: the extreme ticks rounded inward to the spacing (the widest legal position) */
28
+ export declare function fullRange(tickSpacing: number): [number, number];
29
+ /** ±rangeBp of price around `tick`, widened outward to the tick grid, always containing the price (0 = full range) — as the backtests */
30
+ export declare function rangeTicks(tick: number, rangeBp: number, tickSpacing: number): [number, number];
31
+ /** the liquidity the position manager mints for these desired amounts (LiquidityAmounts.getLiquidityForAmounts) */
32
+ export declare function liquidityForAmounts(sp: bigint, sa: bigint, sb: bigint, amount0: bigint, amount1: bigint): bigint;
33
+ /** the token amounts liquidity `l` takes at price `sp` (rounded up = what a mint pulls; down = what it is worth) */
34
+ export declare function amountsForLiquidity(sp: bigint, sa: bigint, sb: bigint, l: bigint, up: boolean): [bigint, bigint];
35
+ /**
36
+ * Given one side's amount, the other side the range needs at the current price (0 when the range takes only one
37
+ * token at this price; null when the given side is not used at all there).
38
+ */
39
+ export declare function otherAmount(side: 0 | 1, amount: bigint, sp: bigint, tickLower: number, tickUpper: number): bigint | null;
40
+ /** which tokens a range takes at this price */
41
+ export declare function sidesUsed(sp: bigint, tickLower: number, tickUpper: number): {
42
+ token0: boolean;
43
+ token1: boolean;
44
+ };
45
+ export interface MintAmounts {
46
+ liquidity: bigint;
47
+ amount0Desired: bigint;
48
+ amount1Desired: bigint;
49
+ amount0Min: bigint;
50
+ amount1Min: bigint;
51
+ }
52
+ /**
53
+ * The amounts to send for these desired amounts, and the minimums under a price move of up to `slippageBps` before the
54
+ * transaction lands. The position manager re-derives the liquidity from the desired amounts AT THE PRICE IT MEETS, so
55
+ * the minimum of each token is what it would actually take at the worse end of the band — token0 at the upper price,
56
+ * token1 at the lower (each is monotonic in the price) — never the liquidity fixed now valued there (that is higher
57
+ * than what a moved price takes, and would revert a move well inside the tolerance).
58
+ */
59
+ export declare function mintAmounts(sp: bigint, tickLower: number, tickUpper: number, desired0: bigint, desired1: bigint, slippageBps: number): MintAmounts;
60
+ export declare const ERC20: readonly [{
61
+ readonly type: "function";
62
+ readonly name: "approve";
63
+ readonly stateMutability: "nonpayable";
64
+ readonly inputs: readonly [{
65
+ readonly name: "spender";
66
+ readonly type: "address";
67
+ }, {
68
+ readonly name: "amount";
69
+ readonly type: "uint256";
70
+ }];
71
+ readonly outputs: readonly [{
72
+ readonly type: "bool";
73
+ }];
74
+ }, {
75
+ readonly type: "function";
76
+ readonly name: "allowance";
77
+ readonly stateMutability: "view";
78
+ readonly inputs: readonly [{
79
+ readonly name: "owner";
80
+ readonly type: "address";
81
+ }, {
82
+ readonly name: "spender";
83
+ readonly type: "address";
84
+ }];
85
+ readonly outputs: readonly [{
86
+ readonly type: "uint256";
87
+ }];
88
+ }, {
89
+ readonly type: "function";
90
+ readonly name: "balanceOf";
91
+ readonly stateMutability: "view";
92
+ readonly inputs: readonly [{
93
+ readonly name: "owner";
94
+ readonly type: "address";
95
+ }];
96
+ readonly outputs: readonly [{
97
+ readonly type: "uint256";
98
+ }];
99
+ }];
100
+ /** the first two fields of slot0 — the same on Uniswap v3, PancakeSwap v3 and Slipstream (the rest differ) */
101
+ export declare const SLOT0: readonly [{
102
+ readonly type: "function";
103
+ readonly name: "slot0";
104
+ readonly stateMutability: "view";
105
+ readonly inputs: readonly [];
106
+ readonly outputs: readonly [{
107
+ readonly name: "sqrtPriceX96";
108
+ readonly type: "uint160";
109
+ }, {
110
+ readonly name: "tick";
111
+ readonly type: "int24";
112
+ }];
113
+ }];
114
+ export interface Call {
115
+ to: Address;
116
+ data: Hex;
117
+ value: bigint;
118
+ }
119
+ export interface MintInput {
120
+ chain: string;
121
+ dex: string;
122
+ token0: Address;
123
+ token1: Address;
124
+ fee: number;
125
+ tickSpacing: number;
126
+ tickLower: number;
127
+ tickUpper: number;
128
+ amounts: MintAmounts;
129
+ recipient: Address;
130
+ deadline: bigint;
131
+ /** pay this side in the native coin (it must be the wrapped native token): sent as value, the change refunded */
132
+ nativeSide: 0 | 1 | null;
133
+ }
134
+ /** the mint transaction (with the native coin: multicall(mint, refundETH) carrying the value) */
135
+ export declare function mintCall(m: MintInput): Call;
136
+ export declare function approveCall(token: Address, spender: Address, amount: bigint): Call;
137
+ /** the approvals a mint needs, given the current allowances (exact amounts, never unlimited) */
138
+ export declare function approvalsNeeded(chain: string, npm: Address, needs: {
139
+ token: Address;
140
+ amount: bigint;
141
+ allowance: bigint;
142
+ }[]): Call[];
143
+ /** token units ⇄ text, exact (no floats) */
144
+ export declare function parseUnits(text: string, decimals: number): bigint | null;
145
+ export declare function formatUnits(v: bigint, decimals: number, maxFrac?: number): string;
146
+ /** human price of token0 in token1 at a tick */
147
+ export declare const priceAtTick: (tick: number, decimals0: number, decimals1: number) => number;
148
+ export interface LpPool {
149
+ chain: string;
150
+ dex: string;
151
+ address: Address;
152
+ token0: Address;
153
+ token1: Address;
154
+ decimals0: number;
155
+ decimals1: number;
156
+ fee: number;
157
+ tickSpacing: number;
158
+ }
159
+ export interface MintPlan {
160
+ tickLower: number;
161
+ tickUpper: number;
162
+ amounts: MintAmounts;
163
+ approvals: Call[];
164
+ mint: Call;
165
+ }
166
+ /**
167
+ * Everything a mint needs, from the pool's live price and what the user wants to put in: the range (±rangeBp around
168
+ * the price, on the grid), the amounts and minimums, the approvals still missing (for the amounts the user entered,
169
+ * so a re-plan at a fresh price before the mint never needs another one), and the mint itself.
170
+ */
171
+ export declare function planMint(pool: LpPool, slot0: {
172
+ sqrtPriceX96: bigint;
173
+ tick: number;
174
+ }, input: {
175
+ rangeBp: number;
176
+ desired0: bigint;
177
+ desired1: bigint;
178
+ slippageBps: number;
179
+ nativeSide: 0 | 1 | null;
180
+ recipient: Address;
181
+ deadline: bigint;
182
+ allowance0: bigint;
183
+ allowance1: bigint;
184
+ }): MintPlan;
185
+ /** the position-manager calls for reading and removing a position (the same on Uniswap v3, PancakeSwap v3, Slipstream) */
186
+ export declare const NPM_READ: readonly [{
187
+ readonly type: "function";
188
+ readonly name: "balanceOf";
189
+ readonly stateMutability: "view";
190
+ readonly inputs: readonly [{
191
+ readonly name: "owner";
192
+ readonly type: "address";
193
+ }];
194
+ readonly outputs: readonly [{
195
+ readonly type: "uint256";
196
+ }];
197
+ }, {
198
+ readonly type: "function";
199
+ readonly name: "tokenOfOwnerByIndex";
200
+ readonly stateMutability: "view";
201
+ readonly inputs: readonly [{
202
+ readonly name: "owner";
203
+ readonly type: "address";
204
+ }, {
205
+ readonly name: "index";
206
+ readonly type: "uint256";
207
+ }];
208
+ readonly outputs: readonly [{
209
+ readonly type: "uint256";
210
+ }];
211
+ }, {
212
+ readonly type: "function";
213
+ readonly name: "positions";
214
+ readonly stateMutability: "view";
215
+ readonly inputs: readonly [{
216
+ readonly name: "tokenId";
217
+ readonly type: "uint256";
218
+ }];
219
+ readonly outputs: readonly [{
220
+ readonly name: "nonce";
221
+ readonly type: "uint96";
222
+ }, {
223
+ readonly name: "operator";
224
+ readonly type: "address";
225
+ }, {
226
+ readonly name: "token0";
227
+ readonly type: "address";
228
+ }, {
229
+ readonly name: "token1";
230
+ readonly type: "address";
231
+ }, {
232
+ readonly name: "feeOrSpacing";
233
+ readonly type: "int24";
234
+ }, {
235
+ readonly name: "tickLower";
236
+ readonly type: "int24";
237
+ }, {
238
+ readonly name: "tickUpper";
239
+ readonly type: "int24";
240
+ }, {
241
+ readonly name: "liquidity";
242
+ readonly type: "uint128";
243
+ }, {
244
+ readonly name: "feeGrowthInside0LastX128";
245
+ readonly type: "uint256";
246
+ }, {
247
+ readonly name: "feeGrowthInside1LastX128";
248
+ readonly type: "uint256";
249
+ }, {
250
+ readonly name: "tokensOwed0";
251
+ readonly type: "uint128";
252
+ }, {
253
+ readonly name: "tokensOwed1";
254
+ readonly type: "uint128";
255
+ }];
256
+ }];
257
+ export declare const COLLECT_ABI: readonly [{
258
+ readonly type: "function";
259
+ readonly name: "decreaseLiquidity";
260
+ readonly stateMutability: "payable";
261
+ readonly inputs: readonly [{
262
+ readonly name: "params";
263
+ readonly type: "tuple";
264
+ readonly components: readonly [{
265
+ readonly name: "tokenId";
266
+ readonly type: "uint256";
267
+ }, {
268
+ readonly name: "liquidity";
269
+ readonly type: "uint128";
270
+ }, {
271
+ readonly name: "amount0Min";
272
+ readonly type: "uint256";
273
+ }, {
274
+ readonly name: "amount1Min";
275
+ readonly type: "uint256";
276
+ }, {
277
+ readonly name: "deadline";
278
+ readonly type: "uint256";
279
+ }];
280
+ }];
281
+ readonly outputs: readonly [{
282
+ readonly name: "amount0";
283
+ readonly type: "uint256";
284
+ }, {
285
+ readonly name: "amount1";
286
+ readonly type: "uint256";
287
+ }];
288
+ }, {
289
+ readonly type: "function";
290
+ readonly name: "collect";
291
+ readonly stateMutability: "payable";
292
+ readonly inputs: readonly [{
293
+ readonly name: "params";
294
+ readonly type: "tuple";
295
+ readonly components: readonly [{
296
+ readonly name: "tokenId";
297
+ readonly type: "uint256";
298
+ }, {
299
+ readonly name: "recipient";
300
+ readonly type: "address";
301
+ }, {
302
+ readonly name: "amount0Max";
303
+ readonly type: "uint128";
304
+ }, {
305
+ readonly name: "amount1Max";
306
+ readonly type: "uint128";
307
+ }];
308
+ }];
309
+ readonly outputs: readonly [{
310
+ readonly name: "amount0";
311
+ readonly type: "uint256";
312
+ }, {
313
+ readonly name: "amount1";
314
+ readonly type: "uint256";
315
+ }];
316
+ }, {
317
+ readonly type: "function";
318
+ readonly name: "multicall";
319
+ readonly stateMutability: "payable";
320
+ readonly inputs: readonly [{
321
+ readonly name: "data";
322
+ readonly type: "bytes[]";
323
+ }];
324
+ readonly outputs: readonly [{
325
+ readonly name: "results";
326
+ readonly type: "bytes[]";
327
+ }];
328
+ }];
329
+ export interface Position {
330
+ tokenId: bigint;
331
+ token0: Address;
332
+ token1: Address;
333
+ feeOrSpacing: number;
334
+ tickLower: number;
335
+ tickUpper: number;
336
+ liquidity: bigint;
337
+ }
338
+ /** whether a position belongs to this pool (same tokens and fee — Slipstream: tick spacing) */
339
+ export declare function inPool(pos: Position, pool: LpPool): boolean;
340
+ /**
341
+ * Taking `share` (basis points of the position, 1..10000) out: the liquidity, what it is worth now, and the minimums
342
+ * under a price move of up to `slippageBps` (each token at its worse price, as the Uniswap SDK's burn rule).
343
+ */
344
+ export declare function removeAmounts(sp: bigint, pos: Position, shareBps: number, slippageBps: number): {
345
+ liquidity: bigint;
346
+ amount0: bigint;
347
+ amount1: bigint;
348
+ amount0Min: bigint;
349
+ amount1Min: bigint;
350
+ };
351
+ /** collect everything owed (fees + what was decreased) to the wallet — the same call simulated shows the uncollected fees */
352
+ export declare function collectCall(chain: string, dex: string, tokenId: bigint, recipient: Address): Call;
353
+ /**
354
+ * One transaction: decrease the liquidity (with minimums and a deadline), then collect it all — principal and fees —
355
+ * to the wallet itself. `liquidity` 0 = fees only (collect).
356
+ */
357
+ export declare function removeCall(chain: string, dex: string, tokenId: bigint, amounts: {
358
+ liquidity: bigint;
359
+ amount0Min: bigint;
360
+ amount1Min: bigint;
361
+ }, recipient: Address, deadline: bigint): Call;