@w3lib/solana-batch-call 0.5.0 → 0.7.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 +47 -0
- package/README.md +129 -9
- package/dist/accountReader.d.ts +57 -0
- package/dist/accountReader.d.ts.map +1 -0
- package/dist/accountReader.js +149 -0
- package/dist/accountReader.js.map +1 -0
- package/dist/address.d.ts +9 -0
- package/dist/address.d.ts.map +1 -1
- package/dist/address.js +36 -6
- package/dist/address.js.map +1 -1
- package/dist/approvals.d.ts +5 -3
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +25 -36
- package/dist/approvals.js.map +1 -1
- package/dist/balances.d.ts +178 -0
- package/dist/balances.d.ts.map +1 -0
- package/dist/balances.js +524 -0
- package/dist/balances.js.map +1 -0
- package/dist/batchPlan.d.ts +52 -0
- package/dist/batchPlan.d.ts.map +1 -0
- package/dist/batchPlan.js +64 -0
- package/dist/batchPlan.js.map +1 -0
- package/dist/bytes.d.ts +31 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +74 -0
- package/dist/bytes.js.map +1 -0
- package/dist/claim.d.ts +163 -0
- package/dist/claim.d.ts.map +1 -0
- package/dist/claim.js +197 -0
- package/dist/claim.js.map +1 -0
- package/dist/client.d.ts +26 -41
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +41 -986
- package/dist/client.js.map +1 -1
- package/dist/clientConfig.d.ts +11 -0
- package/dist/clientConfig.d.ts.map +1 -0
- package/dist/clientConfig.js +3 -0
- package/dist/clientConfig.js.map +1 -0
- package/dist/decode.d.ts +22 -0
- package/dist/decode.d.ts.map +1 -0
- package/dist/decode.js +160 -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.map +1 -1
- package/dist/fees.js +2 -12
- package/dist/fees.js.map +1 -1
- package/dist/history.d.ts +125 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +220 -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/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +21 -0
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +50 -69
- package/dist/layout.js.map +1 -1
- package/dist/liquidity/common.js +2 -2
- package/dist/liquidity/common.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 -12
- package/dist/lp.d.ts.map +1 -1
- package/dist/lp.js +158 -70
- 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/portfolio.d.ts.map +1 -1
- package/dist/portfolio.js +3 -8
- package/dist/portfolio.js.map +1 -1
- 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 +192 -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.js +2 -2
- package/dist/risk.js.map +1 -1
- package/dist/rpc.d.ts +30 -7
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +404 -111
- 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 +395 -0
- package/dist/simulate.js.map +1 -0
- package/dist/source.d.ts +8 -4
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +11 -4
- package/dist/source.js.map +1 -1
- package/dist/subpaths/approvals.d.ts +3 -3
- package/dist/subpaths/approvals.d.ts.map +1 -1
- package/dist/subpaths/approvals.js.map +1 -1
- 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/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/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/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/swap/index.js +3 -3
- package/dist/swap/index.js.map +1 -1
- package/dist/tokenDetails.d.ts +12 -0
- package/dist/tokenDetails.d.ts.map +1 -0
- package/dist/tokenDetails.js +196 -0
- package/dist/tokenDetails.js.map +1 -0
- package/dist/tokenMeta.d.ts +63 -0
- package/dist/tokenMeta.d.ts.map +1 -0
- package/dist/tokenMeta.js +226 -0
- package/dist/tokenMeta.js.map +1 -0
- package/dist/tradeCheck.js +3 -3
- package/dist/tradeCheck.js.map +1 -1
- package/dist/util.d.ts +22 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +107 -0
- package/dist/util.js.map +1 -0
- package/dist/write/chain.d.ts +51 -0
- package/dist/write/chain.d.ts.map +1 -0
- package/dist/write/chain.js +184 -0
- package/dist/write/chain.js.map +1 -0
- package/dist/write/collect.js +2 -2
- package/dist/write/collect.js.map +1 -1
- package/dist/write/errors.d.ts +10 -0
- package/dist/write/errors.d.ts.map +1 -0
- package/dist/write/errors.js +77 -0
- package/dist/write/errors.js.map +1 -0
- package/dist/write/instructions.d.ts +2 -0
- package/dist/write/instructions.d.ts.map +1 -1
- package/dist/write/instructions.js +16 -0
- package/dist/write/instructions.js.map +1 -1
- package/dist/write/pack.d.ts +28 -0
- package/dist/write/pack.d.ts.map +1 -0
- package/dist/write/pack.js +125 -0
- package/dist/write/pack.js.map +1 -0
- package/dist/write/send.d.ts +23 -9
- package/dist/write/send.d.ts.map +1 -1
- package/dist/write/send.js +53 -351
- package/dist/write/send.js.map +1 -1
- package/dist/write/tracker.d.ts +24 -0
- package/dist/write/tracker.d.ts.map +1 -0
- package/dist/write/tracker.js +129 -0
- package/dist/write/tracker.js.map +1 -0
- package/package.json +21 -1
- package/src/accountReader.ts +187 -0
- package/src/address.ts +41 -6
- package/src/approvals.ts +36 -44
- package/src/balances.ts +678 -0
- package/src/batchPlan.ts +117 -0
- package/src/bytes.ts +89 -0
- package/src/claim.ts +327 -0
- package/src/client.ts +59 -1239
- package/src/clientConfig.ts +12 -0
- package/src/decode.ts +176 -0
- package/src/describe.ts +77 -0
- package/src/fees.ts +2 -9
- package/src/history.ts +325 -0
- package/src/historyHelius.ts +136 -0
- package/src/index.ts +13 -1
- package/src/layout.ts +63 -82
- package/src/liquidity/common.ts +2 -2
- package/src/liquidity/orca.ts +15 -5
- package/src/liquidity/raydiumClmm.ts +13 -5
- package/src/lp.ts +226 -75
- package/src/lpMath.ts +38 -0
- package/src/portfolio.ts +8 -11
- package/src/programs.ts +216 -0
- package/src/reclaim.ts +550 -0
- package/src/risk.ts +2 -2
- package/src/rpc.ts +476 -118
- package/src/simulate.ts +517 -0
- package/src/source.ts +14 -5
- package/src/subpaths/approvals.ts +3 -2
- package/src/subpaths/claim.ts +54 -0
- package/src/subpaths/history.ts +38 -0
- package/src/subpaths/lp.ts +5 -1
- package/src/subpaths/reclaim.ts +58 -0
- package/src/subpaths/simulate.ts +37 -0
- package/src/swap/index.ts +3 -3
- package/src/tokenDetails.ts +240 -0
- package/src/tokenMeta.ts +270 -0
- package/src/tradeCheck.ts +3 -3
- package/src/util.ts +110 -0
- package/src/write/chain.ts +218 -0
- package/src/write/collect.ts +2 -2
- package/src/write/errors.ts +82 -0
- package/src/write/instructions.ts +17 -0
- package/src/write/pack.ts +142 -0
- package/src/write/send.ts +74 -407
- package/src/write/tracker.ts +152 -0
package/src/reclaim.ts
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 账户回收(子路径 `@w3lib/solana-batch-call/reclaim`):扫描钱包里关闭后能退回租金(SOL)的账户,给出每项和合计能拿回多少 lamports,批量关闭。
|
|
3
|
+
*
|
|
4
|
+
* - 空的代币账户(SPL Token / Token-2022,有暂存转账手续费的先收到 mint 上);冻结的、关闭权限是别人的关不掉,单独列出
|
|
5
|
+
* - 垃圾币:有余额但没有价格 / 价值很小 / 垃圾特征(可疑名字、不可转让等)的代币,先销毁再关闭账户(一个账户的指令原子执行)。
|
|
6
|
+
* 默认只自动勾选有把握的(见 `selected`):有价格且价值超过 `maxJunkUsd` 的、NFT、仓位 NFT 一律不当垃圾币
|
|
7
|
+
* - Wrapped SOL 账户:关闭 = 解包,余额和租金一起退回
|
|
8
|
+
* - 已清空的集中流动性仓位(Orca Whirlpool、Raydium CLMM:流动性、手续费、奖励都是 0):关闭仓位账户并销毁 NFT。还有没领的手续费 / 奖励时列在 blocked,先用 /claim 领取
|
|
9
|
+
*/
|
|
10
|
+
import { decodeAddress, findProgramAddress, InvalidAddressError, isAddress } from './address.js'
|
|
11
|
+
import type { RequestOptions, SolanaClient } from './client.js'
|
|
12
|
+
import { TOKEN_2022_PROGRAM_ID } from './constants.js'
|
|
13
|
+
import { approvals, parseTokenAccountState, type TokenAccountApproval } from './approvals.js'
|
|
14
|
+
import { ORCA_WHIRLPOOL_PROGRAM_ID, RAYDIUM_CLMM_PROGRAM_ID, parseClmmPosition, utf8 } from './lp.js'
|
|
15
|
+
import { orcaClosePosition } from './liquidity/orca.js'
|
|
16
|
+
import { raydiumClosePosition } from './liquidity/raydiumClmm.js'
|
|
17
|
+
import { defillamaPrices, type SolanaPriceSource } from './price.js'
|
|
18
|
+
import { tokenRisks, type SolanaTokenRisk } from './risk.js'
|
|
19
|
+
import { simulatePlan, sendCalls, type PlanSimulation, type PlanSimulationOptions, type PlannedCall } from './batchPlan.js'
|
|
20
|
+
import type { BatchCallOptions } from './write/batch.js'
|
|
21
|
+
import { burnChecked, closeAccount, harvestWithheldTokensToMint } from './write/instructions.js'
|
|
22
|
+
import type { SignerInput } from './write/signer.js'
|
|
23
|
+
import type { WriteResult } from './write/send.js'
|
|
24
|
+
|
|
25
|
+
export type ReclaimKind = 'empty-token' | 'junk-token' | 'wrapped-sol' | 'orca-position' | 'raydium-position'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 关不掉的原因:frozen 账户被冻结;close-authority 关闭权限是别的地址;confidential-balance Token-2022 机密余额不为 0;
|
|
29
|
+
* paused / permissioned-burn 代币暂停了 / 只有指定的地址能销毁(垃圾币销毁不了);owed 仓位还有没领的手续费 / 奖励(先领取)
|
|
30
|
+
*/
|
|
31
|
+
export type ReclaimBlocker = 'frozen' | 'close-authority' | 'confidential-balance' | 'paused' | 'permissioned-burn' | 'owed'
|
|
32
|
+
|
|
33
|
+
/** 能回收租金的一项(字段都是字符串 / 数字 / 布尔 / null,可以直接 JSON.stringify) */
|
|
34
|
+
export interface ReclaimItem {
|
|
35
|
+
kind: ReclaimKind
|
|
36
|
+
/** 要关闭的代币账户(仓位是持有仓位 NFT 的代币账户) */
|
|
37
|
+
account: string
|
|
38
|
+
/** 账户的 owner(签名者) */
|
|
39
|
+
owner: string
|
|
40
|
+
/** 代币的 mint(仓位是 NFT 的 mint) */
|
|
41
|
+
mint: string
|
|
42
|
+
tokenProgram: string
|
|
43
|
+
symbol: string | null
|
|
44
|
+
decimals: number | null
|
|
45
|
+
/** 账户里的余额(最小单位):垃圾币会被销毁,Wrapped SOL 会解包成 SOL */
|
|
46
|
+
amount: string
|
|
47
|
+
formatted: string | null
|
|
48
|
+
/** 余额的美元价值(查到价格时;没有价格为 null) */
|
|
49
|
+
usd: number | null
|
|
50
|
+
/** Token-2022 账户里暂存的转账手续费:不为 0 时关闭前先收到 mint 上(自动处理) */
|
|
51
|
+
withheldAmount: string
|
|
52
|
+
/** 预计退回的 lamports:账户里的全部(Wrapped SOL 含余额;仓位是仓位账户 + NFT 账户 + Token-2022 NFT 一并关闭的 mint),十进制字符串;以模拟的结果为准 */
|
|
53
|
+
lamports: string
|
|
54
|
+
/** 其中的租金(Wrapped SOL 不含包装的余额) */
|
|
55
|
+
rentLamports: string
|
|
56
|
+
/** 建议勾选:空账户、Wrapped SOL、空仓位总是 true;垃圾币只有“有把握”的才是 true(价格查询正常,且有价格但价值不超过 maxJunkUsd、/risk 有垃圾风险标记、或 isJunk 返回 true;光是名字可疑不够) */
|
|
57
|
+
selected: boolean
|
|
58
|
+
/** 判断依据:dust / no-price / spam-name / risk:<flag> / custom / empty-position / unwrap / … */
|
|
59
|
+
reasons: string[]
|
|
60
|
+
/** 仓位:协议、仓位账户和池子 */
|
|
61
|
+
position?: { protocol: 'orca-whirlpool' | 'raydium-clmm'; positionMint: string; position: string; pool: string }
|
|
62
|
+
/** 只在 blocked 里:关不掉的原因 */
|
|
63
|
+
blocker?: ReclaimBlocker
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface Reclaimable {
|
|
67
|
+
owner: string
|
|
68
|
+
/** 能回收的 */
|
|
69
|
+
items: ReclaimItem[]
|
|
70
|
+
/** 暂时关不掉的(带 blocker):冻结的、关闭权限是别人的、还有没领的手续费的仓位等 */
|
|
71
|
+
blocked: ReclaimItem[]
|
|
72
|
+
/** items 的 lamports 之和 */
|
|
73
|
+
totalLamports: string
|
|
74
|
+
/** items 里 selected 的 lamports 之和 */
|
|
75
|
+
selectedLamports: string
|
|
76
|
+
/** 每种的数量和 lamports(只含 items) */
|
|
77
|
+
byKind: Partial<Record<ReclaimKind, { count: number; lamports: string }>>
|
|
78
|
+
/** 提示(价格服务出错时垃圾币不自动勾选等) */
|
|
79
|
+
warnings: string[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** isJunk 回调收到的信息 */
|
|
83
|
+
export interface JunkContext {
|
|
84
|
+
account: TokenAccountApproval
|
|
85
|
+
/** 美元价格;没有时为 null */
|
|
86
|
+
price: number | null
|
|
87
|
+
/** 余额的美元价值;没有价格时为 null */
|
|
88
|
+
usd: number | null
|
|
89
|
+
/** /risk 的检查结果(risk 选项关闭或读不到时为 null) */
|
|
90
|
+
risk: SolanaTokenRisk | null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ReclaimableOptions extends RequestOptions {
|
|
94
|
+
/** 只看这些 mint 的关联代币账户(免费公共节点也能用);不传时列出全部代币账户(getTokenAccountsByOwner,需要支持它的节点) */
|
|
95
|
+
mints?: readonly string[]
|
|
96
|
+
/** 找垃圾币(有余额的代币),默认 true;false 只找空账户、Wrapped SOL 和空仓位 */
|
|
97
|
+
junk?: boolean
|
|
98
|
+
/** 找已清空的集中流动性仓位,默认 true */
|
|
99
|
+
positions?: boolean
|
|
100
|
+
/** 价格:true(默认)用 DefiLlama;false 不查(这时垃圾币都不自动勾选);也可以传自己的价格源(如 combinePrices([defillamaPrices(), onchainPrices()])) */
|
|
101
|
+
prices?: boolean | SolanaPriceSource
|
|
102
|
+
/** 有价格且价值不超过它的代币算“尘埃”(美元,默认 0.01);超过它的代币不会当垃圾币(isJunk 明确返回 true 的除外) */
|
|
103
|
+
maxJunkUsd?: number
|
|
104
|
+
/** 查垃圾特征用 /risk(不可转让、转账手续费很高、已暂停等),默认 true */
|
|
105
|
+
risk?: boolean
|
|
106
|
+
/**
|
|
107
|
+
* 自己判断哪些是垃圾币:返回 true 一定算垃圾币并勾选(NFT 和仓位 NFT 除外),false 一定不算,undefined 用默认判断。
|
|
108
|
+
* 例如白名单:`({ account }) => (mine.has(account.mint) ? false : undefined)`
|
|
109
|
+
*/
|
|
110
|
+
isJunk?: (context: JunkContext) => boolean | undefined
|
|
111
|
+
fetch?: typeof fetch
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @deprecated 改名为 ReclaimableOptions(与其他查询的 options 命名统一),旧名保留 */
|
|
115
|
+
export type ReclaimQuery = ReclaimableOptions
|
|
116
|
+
|
|
117
|
+
/** 垃圾特征里足以自动勾选的风险标记(只有这些:legit 的 RWA / 稳定币常有 permanent-delegate、transfer-hook,不能据此判断) */
|
|
118
|
+
const JUNK_RISK_FLAGS = ['non-transferable', 'high-transfer-fee', 'paused']
|
|
119
|
+
/** 垃圾币常见的名字:链接 / 域名、领奖广告。只作提示(reasons 里的 spam-name),不会让代币被自动勾选 */
|
|
120
|
+
const SPAM_SYMBOL = /https?:|www\.|\.(com|io|xyz|net|org|app|cc|vip|top|site|fun|live|me)\b|t\.me|claim|airdrop|visit|voucher|\$\d/i
|
|
121
|
+
|
|
122
|
+
const NFT_DECIMALS = 0
|
|
123
|
+
const ownedBy = (a: TokenAccountApproval, owner: string) => a.owner === owner
|
|
124
|
+
|
|
125
|
+
/** 能关闭的限制:冻结、关闭权限是别人、机密余额。没有时返回 undefined */
|
|
126
|
+
function closeBlock(a: TokenAccountApproval): ReclaimBlocker | undefined {
|
|
127
|
+
if (a.frozen) return 'frozen'
|
|
128
|
+
if (a.closeBlocker === 'close-authority') return 'close-authority'
|
|
129
|
+
if (a.closeBlocker === 'confidential-balance') return 'confidential-balance'
|
|
130
|
+
return undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const positionPda = (program: string, mint: string) => findProgramAddress([utf8('position'), decodeAddress(mint)], program)
|
|
134
|
+
|
|
135
|
+
function tokenItem(a: TokenAccountApproval, kind: ReclaimKind, extra: { selected: boolean; reasons: string[]; usd: number | null; blocker?: ReclaimBlocker }): ReclaimItem {
|
|
136
|
+
const lamports = BigInt(a.lamports)
|
|
137
|
+
return {
|
|
138
|
+
kind,
|
|
139
|
+
account: a.account,
|
|
140
|
+
owner: a.owner,
|
|
141
|
+
mint: a.mint,
|
|
142
|
+
tokenProgram: a.tokenProgram,
|
|
143
|
+
symbol: a.symbol,
|
|
144
|
+
decimals: a.decimals,
|
|
145
|
+
amount: a.amount,
|
|
146
|
+
formatted: a.formatted,
|
|
147
|
+
usd: extra.usd,
|
|
148
|
+
withheldAmount: a.withheldAmount,
|
|
149
|
+
lamports: lamports.toString(),
|
|
150
|
+
rentLamports: (a.native ? lamports - BigInt(a.amount) : lamports).toString(),
|
|
151
|
+
selected: extra.selected,
|
|
152
|
+
reasons: extra.reasons,
|
|
153
|
+
...(extra.blocker ? { blocker: extra.blocker } : {}),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 扫描钱包里能回收租金的账户(只读取)。需要列出全部代币账户(getTokenAccountsByOwner),免费公共节点不支持时传 `mints` 只看指定代币的 ATA。
|
|
159
|
+
*
|
|
160
|
+
* ```ts
|
|
161
|
+
* const r = await reclaimable(client, owner)
|
|
162
|
+
* r.totalLamports // 全部能拿回的 lamports
|
|
163
|
+
* r.items.filter((i) => i.selected) // 建议勾选的
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
export async function reclaimable(client: SolanaClient, owner: string, options: ReclaimableOptions = {}): Promise<Reclaimable> {
|
|
167
|
+
if (!isAddress(owner)) {
|
|
168
|
+
throw new InvalidAddressError(`Invalid owner address: ${owner}`, owner)
|
|
169
|
+
}
|
|
170
|
+
const { signal } = options
|
|
171
|
+
const maxJunkUsd = options.maxJunkUsd ?? 0.01
|
|
172
|
+
if (!(maxJunkUsd >= 0)) {
|
|
173
|
+
throw new RangeError(`maxJunkUsd must be >= 0: ${maxJunkUsd}`)
|
|
174
|
+
}
|
|
175
|
+
const accounts = (await approvals(client, owner, { mints: options.mints, all: true, signal })).filter((a) => ownedBy(a, owner))
|
|
176
|
+
const items: ReclaimItem[] = []
|
|
177
|
+
const blocked: ReclaimItem[] = []
|
|
178
|
+
const warnings: string[] = []
|
|
179
|
+
const push = (item: ReclaimItem, blocker?: ReclaimBlocker) => (blocker ? blocked.push({ ...item, blocker }) : items.push(item))
|
|
180
|
+
|
|
181
|
+
const nft = (a: TokenAccountApproval) => a.decimals === NFT_DECIMALS && a.amount === '1' && !a.native
|
|
182
|
+
const rest: TokenAccountApproval[] = []
|
|
183
|
+
for (const a of accounts) {
|
|
184
|
+
const blocker = closeBlock(a)
|
|
185
|
+
if (a.native) {
|
|
186
|
+
// Wrapped SOL:空的算空账户,有余额的关闭即解包
|
|
187
|
+
const empty = BigInt(a.amount) === 0n
|
|
188
|
+
push(tokenItem(a, empty ? 'empty-token' : 'wrapped-sol', { selected: true, reasons: [empty ? 'empty' : 'unwrap'], usd: null }), blocker)
|
|
189
|
+
} else if (BigInt(a.amount) === 0n) {
|
|
190
|
+
push(tokenItem(a, 'empty-token', { selected: true, reasons: ['empty'], usd: null }), blocker)
|
|
191
|
+
} else if (!nft(a)) {
|
|
192
|
+
rest.push(a)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (options.positions ?? true) {
|
|
197
|
+
await scanPositions(client, accounts.filter(nft), push, signal)
|
|
198
|
+
}
|
|
199
|
+
if ((options.junk ?? true) && rest.length) {
|
|
200
|
+
await scanJunk(client, rest, options, maxJunkUsd, push, warnings)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const byKind: Reclaimable['byKind'] = {}
|
|
204
|
+
let total = 0n
|
|
205
|
+
let selected = 0n
|
|
206
|
+
for (const item of items) {
|
|
207
|
+
const entry = byKind[item.kind] ?? { count: 0, lamports: '0' }
|
|
208
|
+
byKind[item.kind] = { count: entry.count + 1, lamports: (BigInt(entry.lamports) + BigInt(item.lamports)).toString() }
|
|
209
|
+
total += BigInt(item.lamports)
|
|
210
|
+
if (item.selected) selected += BigInt(item.lamports)
|
|
211
|
+
}
|
|
212
|
+
return { owner, items, blocked, totalLamports: total.toString(), selectedLamports: selected.toString(), byKind, warnings }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 仓位 NFT(精度 0、余额 1):Orca / Raydium CLMM 的仓位账户一次读出,清空了的列出来 */
|
|
216
|
+
async function scanPositions(client: SolanaClient, nfts: readonly TokenAccountApproval[], push: (item: ReclaimItem, blocker?: ReclaimBlocker) => void, signal: AbortSignal | undefined): Promise<void> {
|
|
217
|
+
if (!nfts.length) return
|
|
218
|
+
const protocols = [
|
|
219
|
+
{ protocol: 'orca-whirlpool' as const, program: ORCA_WHIRLPOOL_PROGRAM_ID, kind: 'orca-position' as const },
|
|
220
|
+
{ protocol: 'raydium-clmm' as const, program: RAYDIUM_CLMM_PROGRAM_ID, kind: 'raydium-position' as const },
|
|
221
|
+
]
|
|
222
|
+
const addresses = nfts.flatMap((a) => [...protocols.map((p) => positionPda(p.program, a.mint)), a.mint])
|
|
223
|
+
const read = await client.accounts(addresses, { signal })
|
|
224
|
+
nfts.forEach((a, i) => {
|
|
225
|
+
const base = i * (protocols.length + 1)
|
|
226
|
+
const mintAccount = read[base + protocols.length]
|
|
227
|
+
protocols.forEach((p, j) => {
|
|
228
|
+
const address = addresses[base + j] as string
|
|
229
|
+
const account = read[base + j]
|
|
230
|
+
const state = account ? parseClmmPosition(p.protocol, address, account) : null
|
|
231
|
+
if (!state || !account || state.mint !== a.mint || state.liquidity > 0n) return
|
|
232
|
+
// 仓位 NFT 是 Token-2022(带元数据扩展)时两个程序都连 mint 一起关闭;SPL Token 的 NFT mint 留着(主网模拟核对过)
|
|
233
|
+
const closesMint = a.tokenProgram === TOKEN_2022_PROGRAM_ID
|
|
234
|
+
const rent = account.lamports + BigInt(a.lamports) + (closesMint && mintAccount ? mintAccount.lamports : 0n)
|
|
235
|
+
const owed = state.owed0 > 0n || state.owed1 > 0n || state.rewards.some((r) => r.owed > 0n)
|
|
236
|
+
const item: ReclaimItem = {
|
|
237
|
+
...tokenItem(a, p.kind, { selected: true, reasons: [owed ? 'owed' : 'empty-position'], usd: null }),
|
|
238
|
+
lamports: rent.toString(),
|
|
239
|
+
rentLamports: rent.toString(),
|
|
240
|
+
position: { protocol: p.protocol, positionMint: a.mint, position: address, pool: state.pool },
|
|
241
|
+
}
|
|
242
|
+
push(item, closeBlock(a) ?? (owed ? 'owed' : undefined))
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 有余额的代币:查价格和风险,按保守的规则判断哪些是垃圾币 */
|
|
248
|
+
async function scanJunk(
|
|
249
|
+
client: SolanaClient,
|
|
250
|
+
list: readonly TokenAccountApproval[],
|
|
251
|
+
options: ReclaimableOptions,
|
|
252
|
+
maxJunkUsd: number,
|
|
253
|
+
push: (item: ReclaimItem, blocker?: ReclaimBlocker) => void,
|
|
254
|
+
warnings: string[],
|
|
255
|
+
): Promise<void> {
|
|
256
|
+
const { signal } = options
|
|
257
|
+
const mints = [...new Set(list.map((a) => a.mint))]
|
|
258
|
+
const source = options.prices === false ? null : options.prices && options.prices !== true ? options.prices : defillamaPrices()
|
|
259
|
+
// 价格没查到(关闭 / 服务出错)时不知道哪些值钱:不自动勾选任何垃圾币
|
|
260
|
+
let pricesOk = source !== null
|
|
261
|
+
let priceMap = new Map<string, number>()
|
|
262
|
+
if (source) {
|
|
263
|
+
try {
|
|
264
|
+
const fetchFn = options.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args))
|
|
265
|
+
priceMap = await source.prices({ mints, fetch: fetchFn, signal, client })
|
|
266
|
+
} catch (err) {
|
|
267
|
+
if (signal?.aborted) throw err
|
|
268
|
+
pricesOk = false
|
|
269
|
+
warnings.push(`Price lookup failed (${err instanceof Error ? err.message : String(err)}): junk tokens are not auto-selected`)
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
warnings.push('Prices are off: junk tokens are not auto-selected')
|
|
273
|
+
}
|
|
274
|
+
const risks = new Map<string, SolanaTokenRisk>()
|
|
275
|
+
if (options.risk ?? true) {
|
|
276
|
+
try {
|
|
277
|
+
const found = await tokenRisks(client, mints, { signal })
|
|
278
|
+
found.forEach((r, i) => risks.set(mints[i] as string, r))
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (signal?.aborted) throw err
|
|
281
|
+
warnings.push(`Risk check failed (${err instanceof Error ? err.message : String(err)})`)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (const a of list) {
|
|
285
|
+
const price = priceMap.get(a.mint) ?? null
|
|
286
|
+
const usd = price !== null && a.formatted !== null ? Number(a.formatted) * price : null
|
|
287
|
+
const risk = risks.get(a.mint) ?? null
|
|
288
|
+
const custom = options.isJunk?.({ account: a, price, usd, risk })
|
|
289
|
+
const valuable = usd !== null && usd > maxJunkUsd
|
|
290
|
+
if (custom === false || (valuable && custom !== true)) continue
|
|
291
|
+
const reasons: string[] = []
|
|
292
|
+
let sure = false
|
|
293
|
+
if (custom === true) {
|
|
294
|
+
reasons.push('custom')
|
|
295
|
+
sure = true
|
|
296
|
+
} else {
|
|
297
|
+
if (usd !== null) {
|
|
298
|
+
reasons.push('dust')
|
|
299
|
+
sure = true
|
|
300
|
+
} else {
|
|
301
|
+
reasons.push('no-price')
|
|
302
|
+
}
|
|
303
|
+
if (a.symbol && (SPAM_SYMBOL.test(a.symbol) || a.symbol.length > 24)) {
|
|
304
|
+
reasons.push('spam-name') // 只是提示:名字不能单独作为销毁的依据
|
|
305
|
+
}
|
|
306
|
+
for (const flag of risk?.flags ?? []) {
|
|
307
|
+
if (JUNK_RISK_FLAGS.includes(flag)) {
|
|
308
|
+
reasons.push(`risk:${flag}`)
|
|
309
|
+
sure = true
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
sure &&= pricesOk
|
|
313
|
+
}
|
|
314
|
+
// 销毁不了的:暂停了、只有指定地址能销毁
|
|
315
|
+
const blocker = closeBlock(a) ?? (risk?.pausable?.paused ? 'paused' : risk?.burnAuthority ? 'permissioned-burn' : undefined)
|
|
316
|
+
if (blocker && !sure) continue // 不确定是垃圾币又关不掉的不列
|
|
317
|
+
push(tokenItem(a, 'junk-token', { selected: sure, reasons, usd }), blocker)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// 构建、模拟、发送
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
/** 一个账户要执行的指令(作为一个调用,原子执行) */
|
|
326
|
+
export interface ReclaimCall extends PlannedCall {
|
|
327
|
+
kind: ReclaimKind
|
|
328
|
+
account: string
|
|
329
|
+
item: ReclaimItem
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export interface BuildReclaimOptions extends RequestOptions {
|
|
333
|
+
/** 退回的 SOL 给谁,默认 owner。Raydium CLMM 的仓位账户租金程序固定退给 owner(不能指定),这时 destination 对它们无效并给出警告 */
|
|
334
|
+
destination?: string
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface ReclaimPlan {
|
|
338
|
+
owner: string
|
|
339
|
+
destination: string
|
|
340
|
+
calls: ReclaimCall[]
|
|
341
|
+
/** 重新读取后发现状态变了、没有构建的(余额变了、有人转入了代币、仓位有新的手续费等):每项给出原因 */
|
|
342
|
+
skipped: Array<{ item: ReclaimItem; reason: string }>
|
|
343
|
+
/** 预计退回的 lamports 之和(按重新读到的账户),十进制字符串 */
|
|
344
|
+
lamports: string
|
|
345
|
+
warnings: string[]
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* 构建回收的指令(只读取、不签名):发送前重新读取每个账户的当前状态再核对(owner、mint、余额、冻结、关闭权限、仓位是否清空),
|
|
350
|
+
* 状态变了的不构建、列在 skipped。每个账户一个调用(垃圾币:销毁 → 收暂存手续费 → 关闭,原子执行)。
|
|
351
|
+
* 绝不关闭有余额的账户:空账户余额不为 0 时跳过;垃圾币只销毁和列表里一致的余额(有人又转入了就跳过);只有 Wrapped SOL 带余额关闭(解包)
|
|
352
|
+
*
|
|
353
|
+
* ```ts
|
|
354
|
+
* const r = await reclaimable(client, owner)
|
|
355
|
+
* const plan = await buildReclaim(client, owner, r.items.filter((i) => i.selected))
|
|
356
|
+
* ```
|
|
357
|
+
*/
|
|
358
|
+
export async function buildReclaim(client: SolanaClient, owner: string, items: readonly ReclaimItem[], options: BuildReclaimOptions = {}): Promise<ReclaimPlan> {
|
|
359
|
+
if (!isAddress(owner)) {
|
|
360
|
+
throw new InvalidAddressError(`Invalid owner address: ${owner}`, owner)
|
|
361
|
+
}
|
|
362
|
+
const destination = options.destination ?? owner
|
|
363
|
+
if (!isAddress(destination)) {
|
|
364
|
+
throw new Error(`Invalid destination: ${destination}`)
|
|
365
|
+
}
|
|
366
|
+
const seen = new Set<string>()
|
|
367
|
+
const list: ReclaimItem[] = []
|
|
368
|
+
for (const item of items) {
|
|
369
|
+
if (item.owner !== owner) {
|
|
370
|
+
throw new Error(`Account ${item.account} belongs to ${item.owner}, not ${owner}`)
|
|
371
|
+
}
|
|
372
|
+
if (item.blocker) {
|
|
373
|
+
throw new Error(`Account ${item.account} cannot be reclaimed yet (${item.blocker})`)
|
|
374
|
+
}
|
|
375
|
+
if (!seen.has(item.account)) {
|
|
376
|
+
seen.add(item.account)
|
|
377
|
+
list.push(item)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const addresses = list.flatMap((i) => (i.position ? [i.account, i.position.position] : [i.account]))
|
|
381
|
+
const read = addresses.length ? await client.accounts(addresses, { signal: options.signal }) : []
|
|
382
|
+
const at = new Map(addresses.map((a, i) => [a, read[i] ?? null]))
|
|
383
|
+
|
|
384
|
+
const calls: ReclaimCall[] = []
|
|
385
|
+
const skipped: ReclaimPlan['skipped'] = []
|
|
386
|
+
const warnings: string[] = []
|
|
387
|
+
let lamports = 0n
|
|
388
|
+
for (const item of list) {
|
|
389
|
+
const skip = (reason: string) => skipped.push({ item, reason })
|
|
390
|
+
const account = at.get(item.account)
|
|
391
|
+
const state = account && account.owner === item.tokenProgram ? parseTokenAccountState(account.data) : null
|
|
392
|
+
if (!account || !state) {
|
|
393
|
+
skip('account not found (already closed?)')
|
|
394
|
+
continue
|
|
395
|
+
}
|
|
396
|
+
if (state.owner !== owner || state.mint !== item.mint) {
|
|
397
|
+
skip('account owner or mint changed')
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
if (state.frozen) {
|
|
401
|
+
skip('account is frozen')
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
if (state.closeAuthority !== null && state.closeAuthority !== owner) {
|
|
405
|
+
skip('another address holds the close authority')
|
|
406
|
+
continue
|
|
407
|
+
}
|
|
408
|
+
if (state.confidentialBalance) {
|
|
409
|
+
skip('confidential balance is not zero')
|
|
410
|
+
continue
|
|
411
|
+
}
|
|
412
|
+
const instructions = []
|
|
413
|
+
let expected = account.lamports
|
|
414
|
+
if (item.kind === 'orca-position' || item.kind === 'raydium-position') {
|
|
415
|
+
const position = item.position
|
|
416
|
+
const raw = position ? at.get(position.position) : null
|
|
417
|
+
const parsed = position && raw ? parseClmmPosition(position.protocol, position.position, raw) : null
|
|
418
|
+
if (!position || !parsed || parsed.mint !== item.mint) {
|
|
419
|
+
skip('position account not found')
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
422
|
+
if (state.amount !== 1n) {
|
|
423
|
+
skip('the position NFT is not in this account')
|
|
424
|
+
continue
|
|
425
|
+
}
|
|
426
|
+
if (parsed.liquidity > 0n || parsed.owed0 > 0n || parsed.owed1 > 0n || parsed.rewards.some((r) => r.owed > 0n)) {
|
|
427
|
+
skip('position is not empty (has liquidity or unclaimed fees / rewards)')
|
|
428
|
+
continue
|
|
429
|
+
}
|
|
430
|
+
if (item.kind === 'orca-position') {
|
|
431
|
+
instructions.push(orcaClosePosition(owner, destination, item.mint, item.tokenProgram, item.account))
|
|
432
|
+
} else {
|
|
433
|
+
instructions.push(raydiumClosePosition(owner, item.mint, item.tokenProgram, position.pool, item.account))
|
|
434
|
+
if (destination !== owner && !warnings.some((w) => w.startsWith('Raydium CLMM'))) {
|
|
435
|
+
warnings.push('Raydium CLMM position rent is always returned to the owner, not to the destination')
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
expected = BigInt(item.lamports)
|
|
439
|
+
} else {
|
|
440
|
+
if (item.kind === 'junk-token') {
|
|
441
|
+
if (state.native || state.amount === 0n || state.amount.toString() !== item.amount) {
|
|
442
|
+
skip('balance changed since the scan')
|
|
443
|
+
continue
|
|
444
|
+
}
|
|
445
|
+
if (item.decimals === null || (item.decimals === NFT_DECIMALS && state.amount === 1n)) {
|
|
446
|
+
skip('NFTs and tokens with unknown decimals are never burned')
|
|
447
|
+
continue
|
|
448
|
+
}
|
|
449
|
+
instructions.push(burnChecked(item.account, item.mint, owner, state.amount, item.decimals, item.tokenProgram))
|
|
450
|
+
} else if (item.kind === 'empty-token') {
|
|
451
|
+
if (state.amount !== 0n) {
|
|
452
|
+
skip('account is not empty anymore')
|
|
453
|
+
continue
|
|
454
|
+
}
|
|
455
|
+
} else if (item.kind === 'wrapped-sol') {
|
|
456
|
+
if (!state.native) {
|
|
457
|
+
skip('not a Wrapped SOL account anymore')
|
|
458
|
+
continue
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (item.tokenProgram === TOKEN_2022_PROGRAM_ID && state.withheldAmount > 0n) {
|
|
462
|
+
instructions.push(harvestWithheldTokensToMint(item.mint, [item.account]))
|
|
463
|
+
}
|
|
464
|
+
instructions.push(closeAccount(item.account, destination, owner, item.tokenProgram))
|
|
465
|
+
}
|
|
466
|
+
lamports += expected
|
|
467
|
+
calls.push({ kind: item.kind, account: item.account, owner, instructions, item })
|
|
468
|
+
}
|
|
469
|
+
return { owner, destination, calls, skipped, lamports: lamports.toString(), warnings }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export interface ReclaimSimulationOptions extends Omit<PlanSimulationOptions, 'watch'> {}
|
|
473
|
+
|
|
474
|
+
export interface ReclaimSimulation extends PlanSimulation {
|
|
475
|
+
/**
|
|
476
|
+
* 预计退回 destination 的 lamports(不含手续费)。destination 就是手续费支付者时取模拟里它的 SOL 变化(lamportsSource 为 'simulation',最准);
|
|
477
|
+
* 否则 /simulate 看不到收款地址的 SOL,按构建时读到的账户余额估算('estimate')
|
|
478
|
+
*/
|
|
479
|
+
lamports: string
|
|
480
|
+
lamportsSource: 'simulation' | 'estimate'
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* 用 /simulate 模拟构建好的回收(不签名、不发送):逐笔交易的结果、哪些调用会失败、手续费、预计退回的 SOL
|
|
485
|
+
*
|
|
486
|
+
* ```ts
|
|
487
|
+
* const sim = await simulateReclaim(client, plan)
|
|
488
|
+
* if (!sim.success) console.log(sim.failed) // [{ index, error }]
|
|
489
|
+
* sim.lamports // 预计拿回的 lamports
|
|
490
|
+
* ```
|
|
491
|
+
*/
|
|
492
|
+
export async function simulateReclaim(client: SolanaClient, plan: ReclaimPlan, options: ReclaimSimulationOptions = {}): Promise<ReclaimSimulation> {
|
|
493
|
+
const sim = await simulatePlan(client, plan.calls, { ...options, watch: [plan.destination] })
|
|
494
|
+
const payer = options.feePayer ?? plan.owner
|
|
495
|
+
const exact = payer === plan.destination && sim.success
|
|
496
|
+
return { ...sim, lamports: exact ? sim.payerNativeDelta : plan.lamports, lamportsSource: exact ? 'simulation' : 'estimate' }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export interface ReclaimOptions extends BatchCallOptions, BuildReclaimOptions {
|
|
500
|
+
/** 发送前先用 /simulate 模拟,模拟失败的调用不发送,默认 true */
|
|
501
|
+
simulate?: boolean
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export interface ReclaimCallResult extends WriteResult {
|
|
505
|
+
kind: ReclaimKind
|
|
506
|
+
account: string
|
|
507
|
+
item: ReclaimItem
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export interface ReclaimResult {
|
|
511
|
+
plan: ReclaimPlan
|
|
512
|
+
/** 发送前的模拟(simulate: false 时为 null) */
|
|
513
|
+
simulation: ReclaimSimulation | null
|
|
514
|
+
/** 与 plan.calls 一一对应;dryRun 时 status 为 'simulated' / 'failed',没有签名 */
|
|
515
|
+
results: ReclaimCallResult[]
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* 回收:构建(重新读取账户并核对)→ 模拟 → 用 from 签名发送(钱包同 /write 的 batchCall,多个账户打包进同一笔交易,feePayer 可以替 owner 付手续费)。
|
|
520
|
+
* 一个账户失败不影响其他的。dryRun: true 只构建和模拟(不签名,from 只用来取地址)
|
|
521
|
+
*
|
|
522
|
+
* ```ts
|
|
523
|
+
* const r = await reclaimable(client, owner)
|
|
524
|
+
* const { simulation, results } = await reclaim(client, key, r.items.filter((i) => i.selected), { destination: cold })
|
|
525
|
+
* ```
|
|
526
|
+
*/
|
|
527
|
+
export async function reclaim(client: SolanaClient, from: SignerInput, items: readonly ReclaimItem[], options: ReclaimOptions = {}): Promise<ReclaimResult> {
|
|
528
|
+
const { resolveSigner } = await import('./write/signer.js')
|
|
529
|
+
const owner = (await resolveSigner(from)).address
|
|
530
|
+
const plan = await buildReclaim(client, owner, items, options)
|
|
531
|
+
const feePayer = options.feePayer === undefined ? undefined : (await resolveSigner(options.feePayer)).address
|
|
532
|
+
const simulation = options.simulate === false && !options.dryRun ? null : await simulateReclaim(client, plan, { feePayer, signal: options.signal, batchSize: (options.mode ?? 'pack') === 'tx' ? 1 : options.batchSize })
|
|
533
|
+
const failed = new Map(simulation?.failed.map((f) => [f.index, f.error]))
|
|
534
|
+
const results: ReclaimCallResult[] = []
|
|
535
|
+
const toSend = plan.calls.filter((_, i) => !failed.has(i))
|
|
536
|
+
const sent = options.dryRun ? [] : await sendCalls(client, from, toSend, options)
|
|
537
|
+
let next = 0
|
|
538
|
+
plan.calls.forEach((call, index) => {
|
|
539
|
+
const base = { kind: call.kind, account: call.account, item: call.item }
|
|
540
|
+
const error = failed.get(index)
|
|
541
|
+
if (error !== undefined) {
|
|
542
|
+
results.push({ index, from: owner, status: 'failed', signature: null, slot: null, fee: null, computeUnits: null, error, ...base })
|
|
543
|
+
} else if (options.dryRun) {
|
|
544
|
+
results.push({ index, from: owner, status: 'simulated', signature: null, slot: null, fee: null, computeUnits: null, error: null, ...base })
|
|
545
|
+
} else {
|
|
546
|
+
results.push({ ...(sent[next++] as WriteResult), index, ...base })
|
|
547
|
+
}
|
|
548
|
+
})
|
|
549
|
+
return { plan, simulation, results }
|
|
550
|
+
}
|
package/src/risk.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* 影响持有人的设置(转账手续费、永久授权、转账 hook、不可转让、新账户默认冻结、可暂停、保密转账、mint 可关闭等),一次 getMultipleAccounts 读完。
|
|
4
4
|
* 可选:持币集中度(getTokenLargestAccounts,免费公共节点大多不支持);能不能买、能不能卖用 tradeChecks(在主网模拟“买入 → 卖出”)
|
|
5
5
|
*/
|
|
6
|
-
import { isAddress } from './address.js'
|
|
6
|
+
import { InvalidAddressError, isAddress } from './address.js'
|
|
7
7
|
import type { RequestOptions, SolanaClient } from './client.js'
|
|
8
8
|
import { NATIVE_MINT, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from './constants.js'
|
|
9
9
|
import { findExtension, parseMint, parseTransferFeeConfig, type AccountInfo } from './layout.js'
|
|
@@ -89,7 +89,7 @@ export interface TokenRisksOptions extends RequestOptions {
|
|
|
89
89
|
export async function tokenRisks(client: SolanaClient, mints: readonly string[], options: TokenRisksOptions = {}): Promise<SolanaTokenRisk[]> {
|
|
90
90
|
const keys = mints.map((m) => (client.isNativeMint(m) ? NATIVE_MINT : m))
|
|
91
91
|
for (const m of keys) {
|
|
92
|
-
if (!isAddress(m)) throw new
|
|
92
|
+
if (!isAddress(m)) throw new InvalidAddressError(`Invalid mint address: ${m}`, m)
|
|
93
93
|
}
|
|
94
94
|
const unique = [...new Set(keys)]
|
|
95
95
|
const accounts = await client.accounts([CLOCK_SYSVAR_ID, ...unique], { signal: options.signal })
|