@w3lib/solana-batch-call 0.4.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 +45 -0
- package/README.md +343 -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/batchPlan.d.ts +52 -0
- package/dist/batchPlan.d.ts.map +1 -0
- package/dist/batchPlan.js +85 -0
- package/dist/batchPlan.js.map +1 -0
- package/dist/claim.d.ts +161 -0
- package/dist/claim.d.ts.map +1 -0
- package/dist/claim.js +197 -0
- package/dist/claim.js.map +1 -0
- package/dist/decode.d.ts +22 -0
- package/dist/decode.d.ts.map +1 -0
- package/dist/decode.js +187 -0
- package/dist/decode.js.map +1 -0
- package/dist/describe.d.ts +26 -0
- package/dist/describe.d.ts.map +1 -0
- package/dist/describe.js +58 -0
- package/dist/describe.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/history.d.ts +125 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +231 -0
- package/dist/history.js.map +1 -0
- package/dist/historyHelius.d.ts +52 -0
- package/dist/historyHelius.d.ts.map +1 -0
- package/dist/historyHelius.js +125 -0
- package/dist/historyHelius.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/liquidity/orca.d.ts +6 -0
- package/dist/liquidity/orca.d.ts.map +1 -1
- package/dist/liquidity/orca.js +14 -3
- package/dist/liquidity/orca.js.map +1 -1
- package/dist/liquidity/raydiumClmm.d.ts +6 -0
- package/dist/liquidity/raydiumClmm.d.ts.map +1 -1
- package/dist/liquidity/raydiumClmm.js +12 -5
- package/dist/liquidity/raydiumClmm.js.map +1 -1
- package/dist/lp.d.ts +55 -15
- package/dist/lp.d.ts.map +1 -1
- package/dist/lp.js +98 -46
- package/dist/lp.js.map +1 -1
- package/dist/lpMath.d.ts +22 -0
- package/dist/lpMath.d.ts.map +1 -1
- package/dist/lpMath.js +21 -0
- package/dist/lpMath.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/programs.d.ts +19 -0
- package/dist/programs.d.ts.map +1 -0
- package/dist/programs.js +175 -0
- package/dist/programs.js.map +1 -0
- package/dist/reclaim.d.ts +190 -0
- package/dist/reclaim.d.ts.map +1 -0
- package/dist/reclaim.js +399 -0
- package/dist/reclaim.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 +36 -10
- package/dist/rpc.js.map +1 -1
- package/dist/simulate.d.ts +106 -0
- package/dist/simulate.d.ts.map +1 -0
- package/dist/simulate.js +418 -0
- package/dist/simulate.js.map +1 -0
- 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/claim.d.ts +31 -0
- package/dist/subpaths/claim.d.ts.map +1 -0
- package/dist/subpaths/claim.js +31 -0
- package/dist/subpaths/claim.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/history.d.ts +21 -0
- package/dist/subpaths/history.d.ts.map +1 -0
- package/dist/subpaths/history.js +22 -0
- package/dist/subpaths/history.js.map +1 -0
- package/dist/subpaths/lp.d.ts +2 -2
- package/dist/subpaths/lp.d.ts.map +1 -1
- package/dist/subpaths/lp.js +2 -2
- package/dist/subpaths/lp.js.map +1 -1
- 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/reclaim.d.ts +34 -0
- package/dist/subpaths/reclaim.d.ts.map +1 -0
- package/dist/subpaths/reclaim.js +34 -0
- package/dist/subpaths/reclaim.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/simulate.d.ts +22 -0
- package/dist/subpaths/simulate.d.ts.map +1 -0
- package/dist/subpaths/simulate.js +23 -0
- package/dist/subpaths/simulate.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 +9 -0
- package/dist/write/instructions.d.ts.map +1 -1
- package/dist/write/instructions.js +40 -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 +66 -14
- package/src/approvals.ts +358 -0
- package/src/batchPlan.ts +137 -0
- package/src/claim.ts +323 -0
- package/src/decode.ts +202 -0
- package/src/describe.ts +77 -0
- package/src/fees.ts +259 -0
- package/src/history.ts +336 -0
- package/src/historyHelius.ts +136 -0
- package/src/layout.ts +2 -2
- package/src/liquidity/orca.ts +15 -5
- package/src/liquidity/raydiumClmm.ts +13 -5
- package/src/lp.ts +148 -59
- package/src/lpMath.ts +38 -0
- 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/programs.ts +216 -0
- package/src/reclaim.ts +547 -0
- package/src/risk.ts +243 -0
- package/src/rpc.ts +39 -10
- package/src/simulate.ts +535 -0
- package/src/subpaths/approvals.ts +47 -0
- package/src/subpaths/claim.ts +53 -0
- package/src/subpaths/fees.ts +41 -0
- package/src/subpaths/history.ts +38 -0
- package/src/subpaths/lp.ts +5 -1
- package/src/subpaths/names.ts +45 -0
- package/src/subpaths/price.ts +62 -0
- package/src/subpaths/reclaim.ts +57 -0
- package/src/subpaths/risk.ts +36 -0
- package/src/subpaths/simulate.ts +37 -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 +43 -1
- package/src/write/transaction.ts +62 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 兑换(子路径 `@w3lib/solana-batch-call/swap`):在 Raydium CLMM、Orca Whirlpool、Raydium CPMM、Raydium AMM v4 的池子里找路线
|
|
3
|
+
* (直接兑换,或经过 SOL / USDC / USDT 的两跳),按链上状态在本地报价(照搬各程序的计算),选输出最多的;
|
|
4
|
+
* 按滑点算最少收到的数量,构建指令(SOL 自动包装 / 解包、缺少的代币账户自动创建),发送同 /write
|
|
5
|
+
*/
|
|
6
|
+
import { getAssociatedTokenAddress, isAddress } from '../address.js'
|
|
7
|
+
import type { SolanaClient } from '../client.js'
|
|
8
|
+
import { NATIVE_MINT, TOKEN_PROGRAM_ID } from '../constants.js'
|
|
9
|
+
import { formatUnits, parseTokenAccount, type AccountInfo } from '../layout.js'
|
|
10
|
+
import { createCtx, minus, parseAmount, prepareTokenAccounts, tokenInfo, type Ctx, type MintMeta } from '../liquidity/common.js'
|
|
11
|
+
import { SOLANA_LP_PROTOCOLS, type SolanaLpToken } from '../lp.js'
|
|
12
|
+
import { poolAddresses, type PdaPoolProtocol } from '../pools.js'
|
|
13
|
+
import { batchCall } from '../write/batch.js'
|
|
14
|
+
import { MAX_COMPUTE_UNITS, setComputeUnitLimit, setComputeUnitPrice } from '../write/instructions.js'
|
|
15
|
+
import type { SendOptions, WriteResult, WriteStatus } from '../write/send.js'
|
|
16
|
+
import { resolveSigner, type SignerInput } from '../write/signer.js'
|
|
17
|
+
import { MAX_ACCOUNT_LOCKS, PACKET_DATA_SIZE, compileMessage, transactionSize, type Instruction } from '../write/transaction.js'
|
|
18
|
+
import { ammV4Pool, parseAmmV4Pool } from './amm.js'
|
|
19
|
+
import { CLOCK_SYSVAR_ID, NeedAccounts, parseClock, swapMint, type AccountGetter, type HopQuote, type SolanaSwapProtocol, type SwapEnv, type SwapPool } from './common.js'
|
|
20
|
+
import { cpmmPool, parseCpmmPool } from './cpmm.js'
|
|
21
|
+
import { parseWhirlpool, whirlpoolPool } from './orca.js'
|
|
22
|
+
import { parseRaydiumClmmPool, raydiumClmmPool } from './raydiumClmm.js'
|
|
23
|
+
|
|
24
|
+
export type { SolanaSwapProtocol } from './common.js'
|
|
25
|
+
|
|
26
|
+
const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
|
|
27
|
+
const USDT = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'
|
|
28
|
+
/** 两跳默认的中间代币:Wrapped SOL、USDC、USDT */
|
|
29
|
+
export const SWAP_INTERMEDIATES: readonly string[] = [NATIVE_MINT, USDC, USDT]
|
|
30
|
+
/** 默认的协议:能按两个 mint 推导池子地址的三个,加上 pools 里给出的 AMM v4 池子 */
|
|
31
|
+
export const SWAP_PROTOCOLS: readonly SolanaSwapProtocol[] = ['raydium-clmm', 'orca-whirlpool', 'raydium-cpmm', 'raydium-amm']
|
|
32
|
+
|
|
33
|
+
type Amount = bigint | number | string
|
|
34
|
+
/** 兑换走得比预读的 tick 数组远时,最多再读几轮 */
|
|
35
|
+
const MAX_LOAD_ROUNDS = 3
|
|
36
|
+
|
|
37
|
+
export interface SolanaSwapParams {
|
|
38
|
+
/** 卖出 / 买入的代币 mint;原生 SOL 用 NATIVE_MINT(So111…112)或客户端的 nativeMints,池子里按 Wrapped SOL */
|
|
39
|
+
tokenIn: string
|
|
40
|
+
tokenOut: string
|
|
41
|
+
/** 卖出的数量:amountIn(最小单位)和 amountInFormatted(按精度换算,如 '1.5')二选一 */
|
|
42
|
+
amountIn?: Amount
|
|
43
|
+
amountInFormatted?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SolanaSwapOptions {
|
|
47
|
+
/** 价格滑点(比例,0.005 = 0.5%),最少收到 = 报价 × (1 − slippage),默认 0.005 */
|
|
48
|
+
slippage?: number
|
|
49
|
+
/**
|
|
50
|
+
* Wrapped SOL 用 SOL 支付 / 收取(自动包装 / 解包),默认 true。
|
|
51
|
+
* 用的是 wSOL 的关联代币账户:原来没有时创建、用完关闭(换回 SOL);原来就有时只补差额、不关闭(买入的 SOL 留在 wSOL 账户里)
|
|
52
|
+
*/
|
|
53
|
+
native?: boolean
|
|
54
|
+
/** 只用这些协议,默认 SWAP_PROTOCOLS(全部四个) */
|
|
55
|
+
protocols?: readonly SolanaSwapProtocol[]
|
|
56
|
+
/** 两跳的中间代币,默认 SWAP_INTERMEDIATES(Wrapped SOL、USDC、USDT);传 [] 只找直接兑换 */
|
|
57
|
+
intermediates?: readonly string[]
|
|
58
|
+
/**
|
|
59
|
+
* 额外的池子地址(四个协议都可以,按账户所属程序识别)。Raydium AMM v4 的池子地址不能从两个 mint 推导,要用的话在这里给出;
|
|
60
|
+
* 用 seed_index 建的 Raydium CLMM 池子、非默认配置的 Orca 池子同样
|
|
61
|
+
*/
|
|
62
|
+
pools?: readonly string[]
|
|
63
|
+
/** 按两个 mint 推导池子地址(Raydium CLMM / CPMM 的每个 AmmConfig、Orca 的每个费率档位),默认 true;false 时只用 pools 里的 */
|
|
64
|
+
discover?: boolean
|
|
65
|
+
signal?: AbortSignal
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface SolanaSwapHop {
|
|
69
|
+
protocol: SolanaSwapProtocol
|
|
70
|
+
protocolName: string
|
|
71
|
+
pool: string
|
|
72
|
+
tokenIn: string
|
|
73
|
+
tokenOut: string
|
|
74
|
+
/** 这一跳卖出的数量和预计收到的数量(已扣转账手续费) */
|
|
75
|
+
amountIn: string
|
|
76
|
+
amountOut: string
|
|
77
|
+
/** 这一跳的最少收到(按滑点),交易里的 other_amount_threshold */
|
|
78
|
+
amountOutMin: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface SolanaSwapRoute {
|
|
82
|
+
/** 经过的代币(池子里的 mint,SOL 是 Wrapped SOL) */
|
|
83
|
+
path: string[]
|
|
84
|
+
hops: SolanaSwapHop[]
|
|
85
|
+
/** 预计收到的数量(最小单位) */
|
|
86
|
+
amountOut: string
|
|
87
|
+
/**
|
|
88
|
+
* 用于比较路线的数量:两跳时第二跳按第一跳的报价全部卖出能拿到的(实际第二跳只卖第一跳的最少收到,多出的中间代币留在账户里,见 leftover);
|
|
89
|
+
* 直接兑换等于 amountOut
|
|
90
|
+
*/
|
|
91
|
+
value: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface SolanaSwapStep {
|
|
95
|
+
/** setup:创建代币账户(能和兑换放进同一笔交易时会合并);swap:包装 SOL、兑换、解包 */
|
|
96
|
+
kind: 'setup' | 'swap'
|
|
97
|
+
instructions: Instruction[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 兑换的计划:报价 + 按顺序执行的指令。只读取、不签名,可以当作报价 */
|
|
101
|
+
export interface SolanaSwapPlan {
|
|
102
|
+
owner: string
|
|
103
|
+
tokenIn: SolanaLpToken
|
|
104
|
+
tokenOut: SolanaLpToken
|
|
105
|
+
amountIn: string
|
|
106
|
+
amountInFormatted: string | null
|
|
107
|
+
/** 预计收到的数量(按当前链上状态本地计算,与程序的取整一致)和最少收到的数量(按滑点) */
|
|
108
|
+
amountOut: string
|
|
109
|
+
amountOutFormatted: string | null
|
|
110
|
+
amountOutMin: string
|
|
111
|
+
amountOutMinFormatted: string | null
|
|
112
|
+
/** 1 个 tokenIn 换多少 tokenOut(按这笔的数量,路线的 value) */
|
|
113
|
+
price: number | null
|
|
114
|
+
/** 价格影响(0~1):这笔的价格比极小数量(1 / 10000)的价格差多少;算不出时为 null */
|
|
115
|
+
priceImpact: number | null
|
|
116
|
+
route: SolanaSwapRoute
|
|
117
|
+
/** 所有候选路线,按 value 从高到低(含选中的) */
|
|
118
|
+
routes: SolanaSwapRoute[]
|
|
119
|
+
/** 两跳时留在账户里的中间代币(第一跳报价 − 第一跳最少收到,按当前状态);中间代币是 Wrapped SOL 且原来没有 wSOL 账户时随账户关闭换回 SOL */
|
|
120
|
+
leftover: { mint: string; amount: string } | null
|
|
121
|
+
/** 哪一边用 SOL(自动包装 / 解包) */
|
|
122
|
+
native: 'tokenIn' | 'tokenOut' | null
|
|
123
|
+
/** 没有用上的池子和原因(不支持的代币扩展、需要位图扩展、流动性不足等) */
|
|
124
|
+
skipped: Array<{ protocol: SolanaSwapProtocol; pool: string; reason: string }>
|
|
125
|
+
steps: SolanaSwapStep[]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface Leg {
|
|
129
|
+
pool: SwapPool
|
|
130
|
+
zeroForOne: boolean
|
|
131
|
+
tokenIn: string
|
|
132
|
+
tokenOut: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface Candidate {
|
|
136
|
+
legs: Array<{ leg: Leg; quote: HopQuote; min: bigint }>
|
|
137
|
+
amountOut: bigint
|
|
138
|
+
value: bigint
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 按账户所属程序解析池子(包内使用,测试也用) */
|
|
142
|
+
export function parseSwapPool(account: AccountInfo): SwapPool | null {
|
|
143
|
+
const cpmm = parseCpmmPool(account)
|
|
144
|
+
if (cpmm) return cpmmPool(cpmm)
|
|
145
|
+
const amm = parseAmmV4Pool(account)
|
|
146
|
+
if (amm) return ammV4Pool(amm)
|
|
147
|
+
const clmm = parseRaydiumClmmPool(account)
|
|
148
|
+
if (clmm) return raydiumClmmPool(clmm)
|
|
149
|
+
const orca = parseWhirlpool(account)
|
|
150
|
+
return orca ? whirlpoolPool(orca) : null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err))
|
|
154
|
+
|
|
155
|
+
/** 读到的账户:多轮读取合并 */
|
|
156
|
+
class Snapshot {
|
|
157
|
+
readonly map = new Map<string, AccountInfo | null>()
|
|
158
|
+
get: AccountGetter = (address) => this.map.get(address)
|
|
159
|
+
async load(ctx: Ctx, addresses: readonly string[]): Promise<void> {
|
|
160
|
+
const missing = [...new Set(addresses)].filter((a) => !this.map.has(a))
|
|
161
|
+
if (!missing.length) return
|
|
162
|
+
const accounts = await ctx.client.accounts(missing, { signal: ctx.signal })
|
|
163
|
+
missing.forEach((a, i) => this.map.set(a, accounts[i] ?? null))
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 报价并构建兑换的指令(不签名):选输出最多的路线,最少收到 = 报价 × (1 − slippage)。
|
|
169
|
+
*
|
|
170
|
+
* ```ts
|
|
171
|
+
* const plan = await buildSwap(client, owner, { tokenIn: NATIVE_MINT, tokenOut: USDC, amountInFormatted: '0.5' })
|
|
172
|
+
* plan.amountOutFormatted // '93.41'
|
|
173
|
+
* plan.route.hops // [{ protocol: 'raydium-clmm', pool: '3ucNos4N…', … }]
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
export async function buildSwap(client: SolanaClient, owner: string, params: SolanaSwapParams, options: SolanaSwapOptions = {}): Promise<SolanaSwapPlan> {
|
|
177
|
+
const ctx = createCtx(client, owner, { slippage: options.slippage, native: options.native, signal: options.signal })
|
|
178
|
+
const nativeIn = client.isNativeMint(params.tokenIn)
|
|
179
|
+
const nativeOut = client.isNativeMint(params.tokenOut)
|
|
180
|
+
const tokenIn = nativeIn ? NATIVE_MINT : params.tokenIn
|
|
181
|
+
const tokenOut = nativeOut ? NATIVE_MINT : params.tokenOut
|
|
182
|
+
for (const mint of [tokenIn, tokenOut]) {
|
|
183
|
+
if (!isAddress(mint)) throw new Error(`Invalid mint address: ${mint}`)
|
|
184
|
+
}
|
|
185
|
+
if (tokenIn === tokenOut) throw new Error(nativeIn || nativeOut ? 'Wrapping / unwrapping SOL is not a swap' : 'tokenIn and tokenOut are the same')
|
|
186
|
+
const protocols = new Set(options.protocols ?? SWAP_PROTOCOLS)
|
|
187
|
+
for (const p of protocols) {
|
|
188
|
+
if (!SWAP_PROTOCOLS.includes(p)) throw new Error(`Unknown swap protocol: ${String(p)}`)
|
|
189
|
+
}
|
|
190
|
+
const mids = [...new Set(options.intermediates ?? SWAP_INTERMEDIATES)].filter((m) => m !== tokenIn && m !== tokenOut)
|
|
191
|
+
for (const m of mids) {
|
|
192
|
+
if (!isAddress(m)) throw new Error(`Invalid intermediate mint: ${m}`)
|
|
193
|
+
}
|
|
194
|
+
for (const p of options.pools ?? []) {
|
|
195
|
+
if (!isAddress(p)) throw new Error(`Invalid pool address: ${p}`)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 1. 一次读:候选池子(PDA 推导 + 传入的)、Clock、各代币的 mint
|
|
199
|
+
const pda = new Set([...protocols].filter((p): p is PdaPoolProtocol => p !== 'raydium-amm'))
|
|
200
|
+
const pairs: Array<[string, string]> = [[tokenIn, tokenOut], ...mids.flatMap((m): Array<[string, string]> => [[tokenIn, m], [m, tokenOut]])]
|
|
201
|
+
const derived = options.discover === false ? [] : pairs.flatMap(([a, b]) => poolAddresses(a, b, pda).map((p) => p.address))
|
|
202
|
+
const snapshot = new Snapshot()
|
|
203
|
+
await snapshot.load(ctx, [CLOCK_SYSVAR_ID, tokenIn, tokenOut, ...mids, ...derived, ...(options.pools ?? [])])
|
|
204
|
+
const clock = snapshot.get(CLOCK_SYSVAR_ID)
|
|
205
|
+
if (!clock) throw new Error('Cannot read the Clock sysvar')
|
|
206
|
+
const env: SwapEnv = parseClock(clock.data)
|
|
207
|
+
for (const mint of [tokenIn, tokenOut]) {
|
|
208
|
+
if (!snapshot.get(mint)) throw new Error(`Token mint not found: ${mint}`)
|
|
209
|
+
}
|
|
210
|
+
const metaIn = swapMint(tokenIn, snapshot.get(tokenIn) as AccountInfo)
|
|
211
|
+
const metaOut = swapMint(tokenOut, snapshot.get(tokenOut) as AccountInfo)
|
|
212
|
+
const amountIn = parseAmount(params.amountIn, params.amountInFormatted, metaIn.decimals, 'amountIn')
|
|
213
|
+
if (amountIn === undefined || amountIn <= 0n) throw new Error('amountIn must be greater than 0')
|
|
214
|
+
if (amountIn > (1n << 64n) - 1n) throw new RangeError('amountIn exceeds u64')
|
|
215
|
+
|
|
216
|
+
// 2. 解析池子,按代币对分组
|
|
217
|
+
const tokens = new Set([tokenIn, tokenOut, ...mids])
|
|
218
|
+
const legs: Leg[] = []
|
|
219
|
+
const seen = new Set<string>()
|
|
220
|
+
for (const address of [...derived, ...(options.pools ?? [])]) {
|
|
221
|
+
const account = snapshot.get(address)
|
|
222
|
+
if (!account || seen.has(address)) continue
|
|
223
|
+
const pool = parseSwapPool(account)
|
|
224
|
+
if (!pool || !protocols.has(pool.protocol) || !tokens.has(pool.mint0) || !tokens.has(pool.mint1)) continue
|
|
225
|
+
seen.add(address)
|
|
226
|
+
for (const [a, b] of pairs) {
|
|
227
|
+
if ((pool.mint0 === a && pool.mint1 === b) || (pool.mint0 === b && pool.mint1 === a)) legs.push({ pool, zeroForOne: pool.mint0 === a, tokenIn: a, tokenOut: b })
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!legs.length) throw new Error('No pool found for this pair (directly or through the intermediate tokens) in the supported protocols')
|
|
231
|
+
// 3. 一次读:报价要用的账户(配置、金库、tick 数组、Oracle 等)
|
|
232
|
+
await snapshot.load(ctx, legs.flatMap((l) => l.pool.accounts(l.zeroForOne)))
|
|
233
|
+
|
|
234
|
+
const skipped: SolanaSwapPlan['skipped'] = []
|
|
235
|
+
const skip = (leg: Leg, err: unknown) => {
|
|
236
|
+
if (!skipped.some((s) => s.pool === leg.pool.address)) skipped.push({ protocol: leg.pool.protocol, pool: leg.pool.address, reason: errorText(err) })
|
|
237
|
+
}
|
|
238
|
+
/** 报价还要读的账户(兑换走得比预读的 tick 数组远):读完后重新报价 */
|
|
239
|
+
const pending = new Set<string>()
|
|
240
|
+
let lastRound = false
|
|
241
|
+
const quoteLeg = (leg: Leg, amount: bigint): HopQuote | null => {
|
|
242
|
+
if (amount <= 0n) return null
|
|
243
|
+
try {
|
|
244
|
+
const q = leg.pool.quote(snapshot.get, amount, leg.zeroForOne, env)
|
|
245
|
+
return q.amountOut > 0n ? q : null
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (err instanceof NeedAccounts && !lastRound) {
|
|
248
|
+
err.addresses.forEach((a) => pending.add(a))
|
|
249
|
+
} else {
|
|
250
|
+
skip(leg, err instanceof NeedAccounts ? new Error('The swap needs too many tick arrays (amount too large for this pool)') : err)
|
|
251
|
+
}
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 4. 报价:直接兑换的每个池子;两跳的每个第一跳池子配上最好的第二跳
|
|
257
|
+
const collect = (): Candidate[] => {
|
|
258
|
+
const list: Candidate[] = []
|
|
259
|
+
for (const leg of legs.filter((l) => l.tokenIn === tokenIn && l.tokenOut === tokenOut)) {
|
|
260
|
+
const q = quoteLeg(leg, amountIn)
|
|
261
|
+
if (q) list.push({ legs: [{ leg, quote: q, min: minus(q.amountOut, ctx.slippage) }], amountOut: q.amountOut, value: q.amountOut })
|
|
262
|
+
}
|
|
263
|
+
for (const mid of mids) {
|
|
264
|
+
const seconds = legs.filter((l) => l.tokenIn === mid && l.tokenOut === tokenOut)
|
|
265
|
+
if (!seconds.length) continue
|
|
266
|
+
for (const first of legs.filter((l) => l.tokenIn === tokenIn && l.tokenOut === mid)) {
|
|
267
|
+
const q1 = quoteLeg(first, amountIn)
|
|
268
|
+
if (!q1) continue
|
|
269
|
+
const min1 = minus(q1.amountOut, ctx.slippage)
|
|
270
|
+
let best: Candidate | null = null
|
|
271
|
+
for (const second of seconds) {
|
|
272
|
+
const q2 = quoteLeg(second, min1)
|
|
273
|
+
if (!q2) continue
|
|
274
|
+
const value = quoteLeg(second, q1.amountOut)?.amountOut ?? q2.amountOut
|
|
275
|
+
if (!best || value > best.value) {
|
|
276
|
+
best = {
|
|
277
|
+
legs: [
|
|
278
|
+
{ leg: first, quote: q1, min: min1 },
|
|
279
|
+
{ leg: second, quote: q2, min: minus(q2.amountOut, ctx.slippage) },
|
|
280
|
+
],
|
|
281
|
+
amountOut: q2.amountOut,
|
|
282
|
+
value,
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (best) list.push(best)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return list
|
|
290
|
+
}
|
|
291
|
+
let candidates: Candidate[] = []
|
|
292
|
+
for (let round = 0; ; round++) {
|
|
293
|
+
pending.clear()
|
|
294
|
+
skipped.length = 0
|
|
295
|
+
lastRound = round >= MAX_LOAD_ROUNDS
|
|
296
|
+
candidates = collect()
|
|
297
|
+
if (!pending.size) break
|
|
298
|
+
await snapshot.load(ctx, [...pending])
|
|
299
|
+
}
|
|
300
|
+
candidates.sort((a, b) => (a.value < b.value ? 1 : a.value > b.value ? -1 : 0))
|
|
301
|
+
if (!candidates.length) {
|
|
302
|
+
const reasons = skipped.map((s) => `${s.protocol} ${s.pool}: ${s.reason}`).join('; ')
|
|
303
|
+
throw new Error(`No route can be quoted for this amount${reasons ? ` (${reasons})` : ''}`)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 5. 代币账户:卖出的、买入的、两跳的中间代币
|
|
307
|
+
const metaOf = (mint: string): MintMeta => {
|
|
308
|
+
const m = mint === tokenIn ? metaIn : mint === tokenOut ? metaOut : swapMint(mint, snapshot.get(mint) as AccountInfo)
|
|
309
|
+
return { mint: m.mint, program: m.program, decimals: m.decimals }
|
|
310
|
+
}
|
|
311
|
+
const [infoIn, infoOut] = (await tokenInfo(ctx, [metaOf(tokenIn), metaOf(tokenOut)])) as [SolanaLpToken, SolanaLpToken]
|
|
312
|
+
|
|
313
|
+
// 6. 按顺序取第一条能放进一笔交易的路线
|
|
314
|
+
let chosen: { candidate: Candidate; steps: SolanaSwapStep[]; native: SolanaSwapPlan['native'] } | null = null
|
|
315
|
+
for (const candidate of candidates) {
|
|
316
|
+
const path = [tokenIn, ...candidate.legs.map((l) => l.leg.tokenOut)]
|
|
317
|
+
const metas = path.map(metaOf)
|
|
318
|
+
const accounts = await prepareTokenAccounts(ctx, metas, path.map((_, i) => (i === 0 ? amountIn : null)))
|
|
319
|
+
const swaps = candidate.legs.map(({ leg, quote, min }, i) =>
|
|
320
|
+
leg.pool.instruction(snapshot.get, owner, accounts.accounts[i] as string, accounts.accounts[i + 1] as string, leg.zeroForOne, quote, min),
|
|
321
|
+
)
|
|
322
|
+
const main = [...accounts.wrap, ...swaps, ...accounts.cleanup]
|
|
323
|
+
const probe = compileMessage(owner, [setComputeUnitLimit(MAX_COMPUTE_UNITS), setComputeUnitPrice(1n), ...main], '11111111111111111111111111111111')
|
|
324
|
+
if (transactionSize(probe) > PACKET_DATA_SIZE || probe.accountKeys.length > MAX_ACCOUNT_LOCKS) {
|
|
325
|
+
skipped.push(...candidate.legs.filter((l) => !skipped.some((s) => s.pool === l.leg.pool.address)).map((l) => ({ protocol: l.leg.pool.protocol, pool: l.leg.pool.address, reason: 'The route does not fit in one transaction' })))
|
|
326
|
+
continue
|
|
327
|
+
}
|
|
328
|
+
const nativeIndex = accounts.nativeIndex
|
|
329
|
+
const native = nativeIndex === 0 ? 'tokenIn' : nativeIndex !== null && nativeIndex === path.length - 1 ? 'tokenOut' : null
|
|
330
|
+
chosen = {
|
|
331
|
+
candidate,
|
|
332
|
+
native,
|
|
333
|
+
steps: [
|
|
334
|
+
{ kind: 'setup' as const, instructions: accounts.setup },
|
|
335
|
+
{ kind: 'swap' as const, instructions: main },
|
|
336
|
+
].filter((s) => s.instructions.length > 0),
|
|
337
|
+
}
|
|
338
|
+
break
|
|
339
|
+
}
|
|
340
|
+
if (!chosen) throw new Error('No route fits in one transaction')
|
|
341
|
+
const best = chosen.candidate
|
|
342
|
+
|
|
343
|
+
// 7. 价格影响:同一路线卖出 1 / 10000 的数量
|
|
344
|
+
let priceImpact: number | null = null
|
|
345
|
+
const small = amountIn / 10_000n
|
|
346
|
+
if (small > 0n) {
|
|
347
|
+
let amount: bigint | null = small
|
|
348
|
+
for (const { leg } of best.legs) amount = amount === null ? null : (quoteLeg(leg, amount)?.amountOut ?? null)
|
|
349
|
+
if (amount) priceImpact = Math.max(0, 1 - Number(best.value) / Number(amountIn) / (Number(amount) / Number(small)))
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const amountOutMin = minus(best.amountOut, ctx.slippage)
|
|
353
|
+
const fmt = (v: bigint, t: SolanaLpToken) => (t.decimals === null ? null : formatUnits(v, t.decimals))
|
|
354
|
+
const route = (c: Candidate): SolanaSwapRoute => ({
|
|
355
|
+
path: [tokenIn, ...c.legs.map((l) => l.leg.tokenOut)],
|
|
356
|
+
hops: c.legs.map(({ leg, quote, min }) => ({
|
|
357
|
+
protocol: leg.pool.protocol,
|
|
358
|
+
protocolName: SOLANA_LP_PROTOCOLS[leg.pool.protocol].name,
|
|
359
|
+
pool: leg.pool.address,
|
|
360
|
+
tokenIn: leg.tokenIn,
|
|
361
|
+
tokenOut: leg.tokenOut,
|
|
362
|
+
amountIn: quote.amountIn.toString(),
|
|
363
|
+
amountOut: quote.amountOut.toString(),
|
|
364
|
+
amountOutMin: min.toString(),
|
|
365
|
+
})),
|
|
366
|
+
amountOut: c.amountOut.toString(),
|
|
367
|
+
value: c.value.toString(),
|
|
368
|
+
})
|
|
369
|
+
const first = best.legs[0] as Candidate['legs'][number]
|
|
370
|
+
return {
|
|
371
|
+
owner,
|
|
372
|
+
tokenIn: infoIn,
|
|
373
|
+
tokenOut: infoOut,
|
|
374
|
+
amountIn: amountIn.toString(),
|
|
375
|
+
amountInFormatted: fmt(amountIn, infoIn),
|
|
376
|
+
amountOut: best.amountOut.toString(),
|
|
377
|
+
amountOutFormatted: fmt(best.amountOut, infoOut),
|
|
378
|
+
amountOutMin: amountOutMin.toString(),
|
|
379
|
+
amountOutMinFormatted: fmt(amountOutMin, infoOut),
|
|
380
|
+
price: infoIn.decimals === null || infoOut.decimals === null ? null : Number(best.value) / 10 ** infoOut.decimals / (Number(amountIn) / 10 ** infoIn.decimals),
|
|
381
|
+
priceImpact,
|
|
382
|
+
route: route(best),
|
|
383
|
+
routes: candidates.map(route),
|
|
384
|
+
leftover: best.legs.length > 1 ? { mint: first.leg.tokenOut, amount: (first.quote.amountOut - first.min).toString() } : null,
|
|
385
|
+
native: chosen.native,
|
|
386
|
+
skipped,
|
|
387
|
+
steps: chosen.steps,
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export interface SolanaSwapSendOptions extends SolanaSwapOptions, Omit<SendOptions, 'onUpdate' | 'signal'> {
|
|
392
|
+
/** 发送前检查卖出的余额够不够,默认 true */
|
|
393
|
+
checkBalance?: boolean
|
|
394
|
+
onUpdate?: (result: SolanaSwapStepResult) => void
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export interface SolanaSwapStepResult extends WriteResult {
|
|
398
|
+
kind: SolanaSwapStep['kind']
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export interface SolanaSwapResult {
|
|
402
|
+
plan: SolanaSwapPlan
|
|
403
|
+
/** 与 plan.steps 一一对应 */
|
|
404
|
+
results: SolanaSwapStepResult[]
|
|
405
|
+
/** 兑换那一步的状态和签名 */
|
|
406
|
+
status: WriteStatus
|
|
407
|
+
signature: string | null
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* 兑换:构建(同 buildSwap)后用 from 签名发送(钱包同 /write 的 batchCall)。创建代币账户和兑换能放进同一笔交易时合并成一笔
|
|
412
|
+
*
|
|
413
|
+
* ```ts
|
|
414
|
+
* const r = await swap(client, secretKey, { tokenIn: USDC, tokenOut: NATIVE_MINT, amountInFormatted: '100' }, { slippage: 0.01 })
|
|
415
|
+
* r.status // 'confirmed'
|
|
416
|
+
* ```
|
|
417
|
+
*/
|
|
418
|
+
export async function swap(client: SolanaClient, from: SignerInput, params: SolanaSwapParams, options: SolanaSwapSendOptions = {}): Promise<SolanaSwapResult> {
|
|
419
|
+
const plan = await buildSwap(client, (await resolveSigner(from)).address, params, options)
|
|
420
|
+
if (options.checkBalance ?? true) {
|
|
421
|
+
await checkBalance(client, plan, options.native ?? true, options.signal)
|
|
422
|
+
}
|
|
423
|
+
const { steps } = plan
|
|
424
|
+
const payer = (await resolveSigner(options.feePayer ?? from)).address
|
|
425
|
+
const probe = compileMessage(payer, [setComputeUnitLimit(MAX_COMPUTE_UNITS), setComputeUnitPrice(1n), ...steps.flatMap((s) => s.instructions)], '11111111111111111111111111111111')
|
|
426
|
+
const merged = steps.length > 1 && transactionSize(probe) <= PACKET_DATA_SIZE && probe.accountKeys.length <= MAX_ACCOUNT_LOCKS
|
|
427
|
+
const groups: number[][] = merged ? [steps.map((_, i) => i)] : steps.map((_, i) => [i])
|
|
428
|
+
const kindOf = (i: number) => (steps[i] as SolanaSwapStep).kind
|
|
429
|
+
const raw = await batchCall(
|
|
430
|
+
client,
|
|
431
|
+
groups.map((group) => ({ from, instructions: group.flatMap((i) => (steps[i] as SolanaSwapStep).instructions) })),
|
|
432
|
+
{
|
|
433
|
+
...options,
|
|
434
|
+
mode: 'pack',
|
|
435
|
+
continueOnError: false,
|
|
436
|
+
onUpdate: options.onUpdate ? (r) => (groups[r.index] as number[]).forEach((i) => options.onUpdate?.({ ...r, index: i, kind: kindOf(i) })) : undefined,
|
|
437
|
+
},
|
|
438
|
+
)
|
|
439
|
+
const results = groups.flatMap((group, g) => group.map((i): SolanaSwapStepResult => ({ ...(raw[g] as WriteResult), index: i, kind: kindOf(i) })))
|
|
440
|
+
const last = results[results.length - 1] as SolanaSwapStepResult
|
|
441
|
+
return { plan, results, status: last.status, signature: last.signature }
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** 卖出 SOL 时给手续费、wSOL 和收到代币的 ATA 租金留的 lamports(约 0.01 SOL) */
|
|
445
|
+
export const SWAP_SOL_RESERVE = 10_000_000n
|
|
446
|
+
|
|
447
|
+
/** 卖出的余额:代币读关联代币账户;SOL 读原生余额(扣掉 SWAP_SOL_RESERVE)+ 已有的 wSOL(只包装差额) */
|
|
448
|
+
async function checkBalance(client: SolanaClient, plan: SolanaSwapPlan, native: boolean, signal: AbortSignal | undefined): Promise<void> {
|
|
449
|
+
const mint = plan.tokenIn.mint
|
|
450
|
+
const need = BigInt(plan.amountIn)
|
|
451
|
+
const program = (await client.accounts([mint], { signal }))[0]?.owner ?? TOKEN_PROGRAM_ID
|
|
452
|
+
const ata = getAssociatedTokenAddress(plan.owner, mint, program)
|
|
453
|
+
const [[account], sol] = await Promise.all([client.accounts([ata], { signal }), mint === NATIVE_MINT && native ? client.solBalances([plan.owner], { signal }) : Promise.resolve(null)])
|
|
454
|
+
let have = 0n
|
|
455
|
+
try {
|
|
456
|
+
if (account) have = parseTokenAccount(account.data).amount
|
|
457
|
+
} catch {
|
|
458
|
+
// 不是代币账户
|
|
459
|
+
}
|
|
460
|
+
if (sol?.[0]?.balance) {
|
|
461
|
+
const lamports = BigInt(sol[0].balance)
|
|
462
|
+
have += lamports > SWAP_SOL_RESERVE ? lamports - SWAP_SOL_RESERVE : 0n
|
|
463
|
+
}
|
|
464
|
+
if (have < need) {
|
|
465
|
+
throw new Error(`Insufficient balance of ${mint === NATIVE_MINT && native ? `SOL (keeping ${SWAP_SOL_RESERVE} lamports for fees and rent)` : mint}: need ${need}, have ${have}`)
|
|
466
|
+
}
|
|
467
|
+
}
|