@clonegod/ttd-sol-common 2.0.66 → 2.0.68
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/dist/appconfig/SolanaQuoteAppConfig.d.ts +9 -0
- package/dist/appconfig/SolanaQuoteAppConfig.js +29 -0
- package/dist/appconfig/SolanaTradeAppConfig.d.ts +13 -0
- package/dist/{config → appconfig}/SolanaTradeAppConfig.js +25 -0
- package/dist/appconfig/ensure_core_env.d.ts +1 -0
- package/dist/appconfig/ensure_core_env.js +18 -0
- package/dist/appconfig/index.d.ts +5 -0
- package/dist/appconfig/index.js +21 -0
- package/dist/appconfig/sol_dex_env_args.d.ts +5 -0
- package/dist/appconfig/sol_dex_env_args.js +29 -0
- package/dist/appconfig/sol_env_args.d.ts +17 -0
- package/dist/appconfig/sol_env_args.js +37 -0
- package/dist/common/get_wallet_token_account.js +13 -16
- package/dist/grpc/grpc_provider_registry.d.ts +14 -0
- package/dist/grpc/grpc_provider_registry.js +70 -0
- package/dist/grpc/index.d.ts +1 -0
- package/dist/grpc/index.js +17 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/quote/abstract_dex_quote.d.ts +68 -0
- package/dist/quote/abstract_dex_quote.js +208 -0
- package/dist/quote/chain_ops.d.ts +18 -0
- package/dist/quote/chain_ops.js +66 -0
- package/dist/quote/depth/clmm_depth_calculator.d.ts +42 -0
- package/dist/quote/depth/clmm_depth_calculator.js +173 -0
- package/dist/quote/depth/index.d.ts +20 -0
- package/dist/quote/depth/index.js +100 -0
- package/dist/quote/index.d.ts +9 -0
- package/dist/quote/index.js +9 -0
- package/dist/quote/pool_event.d.ts +20 -0
- package/dist/quote/pool_event.js +22 -0
- package/dist/quote/pool_subscription_registry.d.ts +4 -0
- package/dist/quote/pool_subscription_registry.js +62 -0
- package/dist/quote/quote_amount.d.ts +4 -0
- package/dist/quote/quote_amount.js +24 -0
- package/dist/quote/quote_trace.d.ts +16 -0
- package/dist/quote/quote_trace.js +40 -0
- package/dist/quote/tick/clmm_tick_math.d.ts +5 -0
- package/dist/quote/tick/clmm_tick_math.js +61 -0
- package/dist/quote/tick/index.d.ts +1 -0
- package/dist/{config → quote/tick}/index.js +1 -1
- package/dist/quote/verify/index.d.ts +1 -0
- package/dist/quote/verify/index.js +17 -0
- package/dist/quote/verify/quote_price_verify.d.ts +30 -0
- package/dist/quote/verify/quote_price_verify.js +247 -0
- package/dist/trade/index.d.ts +0 -1
- package/dist/trade/index.js +0 -1
- package/dist/trade/tx_builder.d.ts +1 -1
- package/dist/trade/tx_result_parse.js +1 -0
- package/dist/types/index.d.ts +9 -1
- package/dist/types/index.js +2 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +17 -0
- package/dist/utils/trade_direction.d.ts +14 -0
- package/dist/utils/trade_direction.js +23 -0
- package/package.json +4 -4
- package/src/appconfig/SolanaQuoteAppConfig.ts +55 -0
- package/src/appconfig/SolanaTradeAppConfig.ts +117 -0
- package/src/appconfig/ensure_core_env.ts +28 -0
- package/src/appconfig/index.ts +5 -0
- package/src/appconfig/sol_dex_env_args.ts +52 -0
- package/src/appconfig/sol_env_args.ts +79 -0
- package/src/common/get_wallet_token_account.ts +27 -33
- package/src/grpc/grpc_provider_registry.ts +103 -0
- package/src/grpc/index.ts +1 -0
- package/src/index.ts +3 -0
- package/src/quote/abstract_dex_quote.ts +337 -0
- package/src/quote/chain_ops.ts +91 -0
- package/src/quote/depth/clmm_depth_calculator.ts +321 -0
- package/src/quote/depth/index.ts +167 -0
- package/src/quote/index.ts +9 -0
- package/src/quote/pool_event.ts +82 -0
- package/src/quote/pool_subscription_registry.ts +81 -0
- package/src/quote/quote_amount.ts +37 -0
- package/src/quote/quote_trace.ts +56 -0
- package/src/quote/tick/clmm_tick_math.ts +77 -0
- package/src/quote/tick/index.ts +1 -0
- package/src/quote/verify/index.ts +1 -0
- package/src/quote/verify/quote_price_verify.ts +508 -0
- package/src/trade/index.ts +0 -1
- package/src/trade/tx_builder.ts +1 -1
- package/src/trade/tx_result_parse.ts +1 -0
- package/src/types/index.ts +20 -2
- package/src/utils/index.ts +1 -0
- package/src/utils/trade_direction.ts +68 -0
- package/dist/config/SolanaTradeAppConfig.d.ts +0 -10
- package/dist/config/index.d.ts +0 -1
- package/dist/trade/SolanaTradeAppConfig.d.ts +0 -8
- package/dist/trade/SolanaTradeAppConfig.js +0 -26
- package/src/config/SolanaTradeAppConfig.ts +0 -70
- package/src/config/index.ts +0 -2
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AppConfig, QuoteDepthOutput, QuoteResultType, StandardPoolInfoType,
|
|
3
|
+
log_info, log_warn, on_quote_response, report_quote_candidate,
|
|
4
|
+
} from '@clonegod/ttd-core/dist'
|
|
5
|
+
import { SolanaBlockMetaUpdateEvent } from '../types'
|
|
6
|
+
import { SolanaChainOps } from './chain_ops'
|
|
7
|
+
import { SolPoolEvent } from './pool_event'
|
|
8
|
+
import { QuotePriceVerify, CheckSwapParams } from './verify/quote_price_verify'
|
|
9
|
+
import { getQuoteAmountUsd } from './quote_amount'
|
|
10
|
+
import { QuoteTrace } from './quote_trace'
|
|
11
|
+
|
|
12
|
+
// ── 结构对齐 BSC/SUI 的 abstract_dex_quote([[feedback_cross_chain_consistency]])──
|
|
13
|
+
// 有理由的偏离(Solana 账户快照模型,详见 pool_event.ts §2.1):
|
|
14
|
+
// · 池事件是**账户原始快照**(非 swap event)→ 每个事件都触发 v2 重算 + refreshStateFromEvent 解码覆盖状态;
|
|
15
|
+
// 因此去掉 SUI 的 swap/add/remove 事件分类(debouncedEventTypes)。
|
|
16
|
+
// · 单调水位线用 Solana 账户 writeVersion(= txIndex 对等物);wire 暂未带 → 缺省按"无序号"处理。
|
|
17
|
+
// · 无 v3 PriceFeed(同 SUI,省 do_quote_v3)。
|
|
18
|
+
// · verify swap-amount 对账暂缓(账户快照不含 amount)→ buildSwapVerify 默认 null;cacheQuote 跨源仍生效。
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 流动性一致性策略(设计文档 §2.2 定稿:3 订阅原型)。各 DEX 子类按协议特性声明:
|
|
22
|
+
* - 'snapshot' :单账户原子(DAMMV2 / CLMM 的 swap 路径)——状态全在一个独占账户、swap 只动它,无需门控。
|
|
23
|
+
* - 'slot-gate' :多账户快照 + slot 门控(AMM 两 vault / DLMM lbPair+binArray)——swap 动多个独占账户,
|
|
24
|
+
* 走单触碰的账户 slot 不一致则本拍不推(等下条更新自然重触发)。
|
|
25
|
+
* - 'tx-driven' :事务驱动(DAMM 共享 vault)——账户快照无法按账户归因,状态由交易解析驱动(账户事件路径不报价)。
|
|
26
|
+
*/
|
|
27
|
+
export type ConsistencyPolicy = 'snapshot' | 'slot-gate' | 'tx-driven'
|
|
28
|
+
|
|
29
|
+
/** loadPool 返回的标准摘要 —— 强制每个 DEX 暴露链上读出的标准字段,供 [QUOTE INIT] 统一打印。 */
|
|
30
|
+
export interface PoolInitSummary {
|
|
31
|
+
protocol_type: string // AMM_PUMPSWAP / CLMM_ORCA / DLMM_METEORA / ...
|
|
32
|
+
fee_bps: number // 链上读
|
|
33
|
+
tick_spacing?: number
|
|
34
|
+
bin_step?: number
|
|
35
|
+
token0: string // "SYMBOL(addr)"
|
|
36
|
+
token1: string
|
|
37
|
+
verifyProgram?: string // 启动校验的链上 program(无则省)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 一次询价的完整产物(推 orderbook + verify + analyze 上报共用)。 */
|
|
41
|
+
export interface QuoteBundle {
|
|
42
|
+
poolInfo: StandardPoolInfoType
|
|
43
|
+
quote_amount_usd: number
|
|
44
|
+
streamTimestamp: number
|
|
45
|
+
quoteStartTime: number
|
|
46
|
+
blockNumber: number
|
|
47
|
+
txIndex?: number // 块内序号(v2 = writeVersion;v1 区块兜底无 → 无条件推,不入 watermark)
|
|
48
|
+
askQuote: QuoteResultType
|
|
49
|
+
bidQuote: QuoteResultType
|
|
50
|
+
txid: string
|
|
51
|
+
source?: string
|
|
52
|
+
depth?: QuoteDepthOutput
|
|
53
|
+
priceMap?: Map<string, { price: string } | undefined>
|
|
54
|
+
trace?: QuoteTrace
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* AbstractDexQuote(Solana)—— 询价流程骨架,对齐 BSC/SUI 同名抽象
|
|
59
|
+
* (模板方法 DEX 维继承 + 组合链维 SolanaChainOps)。
|
|
60
|
+
*
|
|
61
|
+
* 两源(Solana 无 v3 pricefeed):
|
|
62
|
+
* - v1(new_block 通道兜底,节流 MIN_QUOTE_INTERVAL_MS)→ quoteV1(SDK / 链上模拟,自取最新状态)
|
|
63
|
+
* - v2(pool 账户更新事件驱动)→ refreshStateFromEvent 解码账户覆盖本地状态 → quoteV2(本地公式)
|
|
64
|
+
*
|
|
65
|
+
* 强制不变量:v1/v2 都基于同一份链上 load 的池子状态;ask 含费恒 ≥ bid。
|
|
66
|
+
*/
|
|
67
|
+
export abstract class AbstractDexQuote<C extends SolanaChainOps = SolanaChainOps> {
|
|
68
|
+
protected appConfig: AppConfig
|
|
69
|
+
protected chain: C
|
|
70
|
+
protected poolInfoMap: Map<string, StandardPoolInfoType> = new Map()
|
|
71
|
+
|
|
72
|
+
private poolLastQuoteTimeMap: Map<string, number> = new Map()
|
|
73
|
+
// 推送单调水位线:每池最近推送的 (block, writeVersion)。严格变新才推。
|
|
74
|
+
private lastPublished: Map<string, { block: number; txIndex: number }> = new Map()
|
|
75
|
+
// 状态应用水位线:防乱序旧账户快照回拉状态(独立于报价水位线)。
|
|
76
|
+
private appliedStateWatermark: Map<string, { block: number; txIndex: number }> = new Map()
|
|
77
|
+
protected quotePriceVerify = new QuotePriceVerify()
|
|
78
|
+
/**
|
|
79
|
+
* 最近一个区块的 slot(由 handleBlockUpdateEvent 维护,stream-block 每 ~400ms tick)。
|
|
80
|
+
* 给 v1(block 兜底)路径用作"当前 slot"——v1 正是 block 触发的,调用时此值=当前块 slot。
|
|
81
|
+
* 用途:DAMM v2 等动态费协议的 fee scheduler 在 activationType=slot 时需要当前 slot 当 currentPoint。
|
|
82
|
+
*/
|
|
83
|
+
protected latestBlockSlot = 0
|
|
84
|
+
private readonly MIN_QUOTE_INTERVAL_MS = Math.max(3000, parseInt(process.env.MIN_QUOTE_INTERVAL_MS || '10000', 10) || 10000)
|
|
85
|
+
|
|
86
|
+
constructor(appConfig: AppConfig, chain: C) {
|
|
87
|
+
this.appConfig = appConfig
|
|
88
|
+
this.chain = chain
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ════════════ DEX 差异点(子类实现)════════════
|
|
92
|
+
|
|
93
|
+
/** 本进程绑定的 DEX_ID(事件过滤 + 日志) */
|
|
94
|
+
protected abstract readonly dexId: string
|
|
95
|
+
/**
|
|
96
|
+
* 流动性一致性策略(§2.2)。默认 'snapshot'(单账户原子,最常见);
|
|
97
|
+
* AMM/DLMM 覆盖为 'slot-gate',DAMM 覆盖为 'tx-driven'。
|
|
98
|
+
*/
|
|
99
|
+
protected readonly consistencyPolicy: ConsistencyPolicy = 'snapshot'
|
|
100
|
+
/** 链上 load 池子静态+动态状态并缓存(含 token 身份校验,fail-loud)。返回标准摘要供 [QUOTE INIT]。 */
|
|
101
|
+
protected abstract loadPool(poolInfo: StandardPoolInfoType): Promise<PoolInitSummary>
|
|
102
|
+
/** v1:SDK / 链上模拟(block 兜底,自取最新状态)。isBuy=true 买基础币(ask)。 */
|
|
103
|
+
protected abstract quoteV1(poolInfo: StandardPoolInfoType, isBuy: boolean): Promise<QuoteResultType>
|
|
104
|
+
/** v2:本地公式(事件驱动;调用前 refreshStateFromEvent 已用账户快照刷新本地状态)。 */
|
|
105
|
+
protected abstract quoteV2(poolInfo: StandardPoolInfoType, isBuy: boolean): Promise<QuoteResultType>
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 用账户快照刷新内存池子状态(**Solana v2 的核心机制**:解码 evt.poolAccountData / vault 数据 → 覆盖本地状态)。
|
|
109
|
+
* 默认 no-op,子类必须覆盖以真正消费账户快照(否则 v2 等于不更新)。
|
|
110
|
+
*/
|
|
111
|
+
protected async refreshStateFromEvent(_poolInfo: StandardPoolInfoType, _evt: SolPoolEvent): Promise<void> {}
|
|
112
|
+
/** 深度(tier 化);fee 来自链上 load。priceMap 由基类预取。默认 undefined(step 3 各 DEX 逐档实现)。 */
|
|
113
|
+
protected async calculateDepth(_poolInfo: StandardPoolInfoType, _poolAddress: string, _priceMap: Map<string, { price: string } | undefined>): Promise<QuoteDepthOutput | undefined> { return undefined }
|
|
114
|
+
/** swap → verify 入参;Solana 账户快照暂不含 swap amount,默认 null(待 stream-quote 解码后接)。 */
|
|
115
|
+
protected buildSwapVerify(_poolInfo: StandardPoolInfoType, _evt: SolPoolEvent): CheckSwapParams | null { return null }
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* slot 门控钩子(仅 consistencyPolicy='slot-gate' 生效):判断本池"走单触碰的多账户"状态是否 slot 一致。
|
|
119
|
+
* 不一致返回 false → 本拍 v2 不报价(落后账户的下一条更新会自然重触发,等价"有界等待下一拍")。
|
|
120
|
+
*
|
|
121
|
+
* 默认 true(snapshot 单账户原子无需门控)。slot-gate 协议覆盖:比较 pool 与刚更新的 vault/binArray 的 slot
|
|
122
|
+
* (子账户 slot 由子类经 recordAccountSlot 维护;step2b 子账户转发到位后填充)。
|
|
123
|
+
*/
|
|
124
|
+
protected isStateConsistentForQuote(_poolInfo: StandardPoolInfoType): boolean { return true }
|
|
125
|
+
|
|
126
|
+
/** 记录某账户最新 slot(供 slot-gate 的 isStateConsistentForQuote 比较;子类/子账户转发路径调用)。 */
|
|
127
|
+
protected accountSlots: Map<string, number> = new Map()
|
|
128
|
+
protected recordAccountSlot(accountAddr: string, slot: number): void {
|
|
129
|
+
this.accountSlots.set(accountAddr, slot)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ════════════ 生命周期(基类)════════════
|
|
133
|
+
|
|
134
|
+
async init(poolList: StandardPoolInfoType[]): Promise<void> {
|
|
135
|
+
log_info(`初始化 ${this.dexId} Quote,共 ${poolList.length} 个池...`)
|
|
136
|
+
for (const poolInfo of poolList) {
|
|
137
|
+
if (!this.chain.isValidPoolAddress(poolInfo.pool_address)) {
|
|
138
|
+
log_warn(`跳过无效的池地址: ${poolInfo.pool_address}`, '')
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
this.poolInfoMap.set(poolInfo.pool_address, poolInfo)
|
|
142
|
+
}
|
|
143
|
+
await this.beforeLoadPools()
|
|
144
|
+
await Promise.all(Array.from(this.poolInfoMap.values()).map(async p => {
|
|
145
|
+
const summary = await this.loadPool(p)
|
|
146
|
+
if (summary.verifyProgram) await this.chain.assertProgramDeployed(summary.verifyProgram)
|
|
147
|
+
log_info(`[QUOTE INIT] ${p.pool_name} (${p.pool_address}) dex=${this.dexId}`, { ...summary })
|
|
148
|
+
}))
|
|
149
|
+
this.registerEventHandlers()
|
|
150
|
+
log_info(`${this.dexId} Quote 初始化完成,${this.poolInfoMap.size} 个有效池`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 子类可选:load 前的批量准备(如 SDK 初始化)。默认空。 */
|
|
154
|
+
protected async beforeLoadPools(): Promise<void> {}
|
|
155
|
+
|
|
156
|
+
/** 询价 USD 金额(QUOTE_AMOUNT_USD 覆盖 / pool 配置)。子类 compute amountIn 用。 */
|
|
157
|
+
protected getQuoteAmountUsd(poolInfo: StandardPoolInfoType): number { return getQuoteAmountUsd(poolInfo) }
|
|
158
|
+
|
|
159
|
+
private registerEventHandlers(): void {
|
|
160
|
+
// v1 兜底:new_block 通道(stream-block)
|
|
161
|
+
this.chain.subscribeNewBlock((raw) => this.handleBlockUpdateEvent(raw))
|
|
162
|
+
// v2 事件:pool 账户更新(stream-quote WS)—— 直连 refresh → compute → publish,(block,writeVersion) 水位线兜底
|
|
163
|
+
this.chain.subscribePoolEvents(this.dexId, Array.from(this.poolInfoMap.values()), async (evt: SolPoolEvent) => {
|
|
164
|
+
const { bundle, verifyLog } = await this.calculateQuote(evt)
|
|
165
|
+
if (bundle) this.publishQuote(bundle)
|
|
166
|
+
// [Verify] 在 [QUOTE OK] 之后打 —— 先报价后验证
|
|
167
|
+
if (verifyLog) log_info(verifyLog)
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ════════════ 事件处理(基类)════════════
|
|
172
|
+
|
|
173
|
+
private assertValidBlockNumber(blockNumber: any, ctx: string): void {
|
|
174
|
+
if (!Number.isInteger(blockNumber) || blockNumber <= 0) {
|
|
175
|
+
throw new Error(`[block-number] ${ctx}: 非法 blockNumber=${blockNumber},每条数据源必须携带真实 slot`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** (block, writeVersion) 水位线判定:incoming 是否相对已推送的最新陈旧(只判定不更新)。 */
|
|
180
|
+
private isStaleUpdate(poolAddr: string, block: number, txIndex: number): boolean {
|
|
181
|
+
const last = this.lastPublished.get(poolAddr)
|
|
182
|
+
return !!last && (block < last.block || (block === last.block && txIndex <= last.txIndex))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 状态应用水位线:乱序晚到的旧账户快照(绝对态)跳过应用,防把状态回拉到旧值。check-and-advance 一体。 */
|
|
186
|
+
private shouldApplyState(poolAddr: string, block: number, txIndex: number): boolean {
|
|
187
|
+
const last = this.appliedStateWatermark.get(poolAddr)
|
|
188
|
+
if (last && (block < last.block || (block === last.block && txIndex <= last.txIndex))) return false
|
|
189
|
+
this.appliedStateWatermark.set(poolAddr, { block, txIndex })
|
|
190
|
+
return true
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async handleBlockUpdateEvent(eventData: string): Promise<void> {
|
|
194
|
+
const { slot, blockTime } = JSON.parse(eventData) as SolanaBlockMetaUpdateEvent
|
|
195
|
+
this.assertValidBlockNumber(slot, `${this.dexId} block-event`)
|
|
196
|
+
this.latestBlockSlot = slot // v1 兜底询价取当前 slot 用(动态费 currentPoint)
|
|
197
|
+
for (const poolInfo of this.poolInfoMap.values()) {
|
|
198
|
+
const poolAddress = poolInfo.pool_address
|
|
199
|
+
const now = Date.now()
|
|
200
|
+
const last = this.poolLastQuoteTimeMap.get(poolAddress) || 0
|
|
201
|
+
if (now - last >= this.MIN_QUOTE_INTERVAL_MS) {
|
|
202
|
+
this.poolLastQuoteTimeMap.set(poolAddress, now) // await 前占位,防节流被打穿
|
|
203
|
+
const result = await this.calculateQuoteForPool(poolInfo, blockTime, slot)
|
|
204
|
+
if (result) this.publishQuote(result)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async calculateQuote(evt: SolPoolEvent): Promise<{ bundle: QuoteBundle | null, verifyLog?: string }> {
|
|
210
|
+
const poolInfo = this.poolInfoMap.get(evt.pool_address)
|
|
211
|
+
if (!poolInfo) return { bundle: null }
|
|
212
|
+
this.assertValidBlockNumber(evt.blockNumber, `${this.dexId} ${poolInfo.pool_name} account-update`)
|
|
213
|
+
|
|
214
|
+
// swap verify(唯一入口)。SOL 账户快照默认无 swap amount → buildSwapVerify 返回 null 即跳过。
|
|
215
|
+
let verifyLog: string | undefined
|
|
216
|
+
const v = this.buildSwapVerify(poolInfo, evt)
|
|
217
|
+
if (v) verifyLog = this.quotePriceVerify.checkSwap(v) || undefined
|
|
218
|
+
|
|
219
|
+
// 状态更新:每个账户快照都做(不受报价 staleness 短路影响)。
|
|
220
|
+
// 例外:乱序晚到的旧 writeVersion 跳过,防状态回拉(writeVersion 缺省时无条件应用)。
|
|
221
|
+
const hasWv = Number.isInteger(evt.writeVersion)
|
|
222
|
+
const skipApply = hasWv && !this.shouldApplyState(evt.pool_address, evt.blockNumber, evt.writeVersion as number)
|
|
223
|
+
if (!skipApply) await this.refreshStateFromEvent(poolInfo, evt)
|
|
224
|
+
|
|
225
|
+
// compute 前短路:已陈旧 → 跳过重算+推送(状态已更新)
|
|
226
|
+
if (hasWv && this.isStaleUpdate(evt.pool_address, evt.blockNumber, evt.writeVersion as number)) return { bundle: null, verifyLog }
|
|
227
|
+
|
|
228
|
+
// slot 门控(slot-gate 协议):走单触碰的多账户 slot 不一致 → 本拍不报价,等落后账户的下一条更新重触发
|
|
229
|
+
if (this.consistencyPolicy === 'slot-gate' && !this.isStateConsistentForQuote(poolInfo)) {
|
|
230
|
+
return { bundle: null, verifyLog }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const bundle = await this.calculateQuoteForPool(poolInfo, evt.blockTime, evt.blockNumber, evt)
|
|
234
|
+
return { bundle, verifyLog }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async calculateQuoteForPool(poolInfo: StandardPoolInfoType, streamTimestamp: number, blockNumber: number, evt?: SolPoolEvent): Promise<QuoteBundle | null> {
|
|
238
|
+
const { pool_address } = poolInfo
|
|
239
|
+
const eventDriven = !!evt
|
|
240
|
+
const source = eventDriven ? 'local:v2' : 'rpc:v1'
|
|
241
|
+
const trace = new QuoteTrace(poolInfo.pool_name, pool_address, blockNumber, source)
|
|
242
|
+
trace.mark('trigger')
|
|
243
|
+
let stage = 'compute'
|
|
244
|
+
try {
|
|
245
|
+
const quote_amount_usd = getQuoteAmountUsd(poolInfo)
|
|
246
|
+
const quoteStartTime = Date.now()
|
|
247
|
+
const txid = evt ? (evt.txHash || 'account') : 'block'
|
|
248
|
+
const txIndex = evt?.writeVersion
|
|
249
|
+
|
|
250
|
+
let askQuote: QuoteResultType, bidQuote: QuoteResultType
|
|
251
|
+
if (eventDriven) {
|
|
252
|
+
;[askQuote, bidQuote] = await Promise.all([this.quoteV2(poolInfo, true), this.quoteV2(poolInfo, false)])
|
|
253
|
+
} else {
|
|
254
|
+
;[askQuote, bidQuote] = await Promise.all([this.quoteV1(poolInfo, true), this.quoteV1(poolInfo, false)])
|
|
255
|
+
}
|
|
256
|
+
trace.mark('compute')
|
|
257
|
+
// 通用 sanity:ask 含 fee 恒 ≥ bid。ask<bid = 方向/公式 bug → fail-loud(不推坏价)
|
|
258
|
+
const askN = Number(askQuote.price), bidN = Number(bidQuote.price)
|
|
259
|
+
if (askN > 0 && bidN > 0 && askN < bidN) {
|
|
260
|
+
throw new Error(`ask(${askN}) < bid(${bidN}) — 异常价差,丢弃该报价`)
|
|
261
|
+
}
|
|
262
|
+
this.poolLastQuoteTimeMap.set(pool_address, Date.now())
|
|
263
|
+
|
|
264
|
+
stage = 'depth'
|
|
265
|
+
// 本次报价只查一次 token 价:传给 calculateDepth,并随 bundle 给 publishQuote 复用
|
|
266
|
+
const t0a = poolInfo.tokenA?.address, t1a = poolInfo.tokenB?.address
|
|
267
|
+
const priceMap = (t0a && t1a) ? await this.chain.loadTokenPrices([t0a, t1a]) : new Map<string, { price: string } | undefined>()
|
|
268
|
+
const depth = await this.calculateDepth(poolInfo, pool_address, priceMap)
|
|
269
|
+
trace.mark('depth')
|
|
270
|
+
trace.set('ask', askQuote.price); trace.set('bid', bidQuote.price)
|
|
271
|
+
trace.set('tiers', depth?.ask?.tiers?.length ?? 0); trace.set('quote_amount_usd', quote_amount_usd); trace.set('txid', txid)
|
|
272
|
+
return { poolInfo, quote_amount_usd, streamTimestamp, quoteStartTime, blockNumber, txIndex, askQuote, bidQuote, txid, source, depth, priceMap, trace }
|
|
273
|
+
} catch (error) {
|
|
274
|
+
trace.markError(stage, error instanceof Error ? error.message : String(error))
|
|
275
|
+
trace.flush()
|
|
276
|
+
return null
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ════════════ 推送 + verify(基类)════════════
|
|
281
|
+
|
|
282
|
+
protected publishQuote(result: QuoteBundle): void {
|
|
283
|
+
const poolAddr = result.poolInfo.pool_address
|
|
284
|
+
const isV2 = result.source?.endsWith(':v2')
|
|
285
|
+
|
|
286
|
+
// ── 步骤4 publish:延迟关键路径,最先做 —— verify/上报绝不挡在推价前面 ──
|
|
287
|
+
let shouldPush = true
|
|
288
|
+
if (result.source?.endsWith(':v1')) {
|
|
289
|
+
shouldPush = true // v1 区块兜底:无条件推(SDK/链上读 latest 恒最新,不参与 watermark)
|
|
290
|
+
} else {
|
|
291
|
+
const incomingTx = result.txIndex
|
|
292
|
+
if (!Number.isInteger(incomingTx)) {
|
|
293
|
+
// writeVersion 缺省(wire 暂未携带):退化为无条件推(对齐 v1 / SUI 缺 txIndex 行为),step 2 补 wire 后自动启用去重
|
|
294
|
+
shouldPush = true
|
|
295
|
+
} else {
|
|
296
|
+
const tx = incomingTx as number
|
|
297
|
+
shouldPush = !this.isStaleUpdate(poolAddr, result.blockNumber, tx)
|
|
298
|
+
if (shouldPush) this.lastPublished.set(poolAddr, { block: result.blockNumber, txIndex: tx })
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (shouldPush) {
|
|
302
|
+
on_quote_response({
|
|
303
|
+
appConfig: this.appConfig, poolInfo: result.poolInfo, quoteAmountUsd: result.quote_amount_usd,
|
|
304
|
+
streamTime: result.streamTimestamp, quoteStartTime: result.quoteStartTime, blockNumber: result.blockNumber,
|
|
305
|
+
quotes: [result.askQuote, result.bidQuote], txid: result.txid, source: result.source, depth: result.depth,
|
|
306
|
+
})
|
|
307
|
+
// v2 留 info 痕(事件驱动天然低频不刷屏);v1 高频维持 debug 级 —— 防"v2 无日志被误判没跑"
|
|
308
|
+
if (isV2) {
|
|
309
|
+
log_info(`[QUOTE OK v2] ${result.poolInfo.pool_name} ask=${result.askQuote.price} bid=${result.bidQuote.price} slot=${result.blockNumber} wv=${result.txIndex ?? '-'} ${result.txid}`)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
result.trace?.set('published', shouldPush); result.trace?.mark('publish')
|
|
313
|
+
|
|
314
|
+
// ── 步骤5 verify:诊断旁路,在推价之后 —— 不阻塞询价。去重与否都执行(供后续对账)──
|
|
315
|
+
const candidateTiers = isV2 && result.depth ? {
|
|
316
|
+
ask_tiers: result.depth.ask.tiers.map(t => ({ pct: t.pct, price: t.price, amount: t.amount, amount_in: t.amount_in })),
|
|
317
|
+
bid_tiers: result.depth.bid.tiers.map(t => ({ pct: t.pct, price: t.price, amount: t.amount, amount_in: t.amount_in })),
|
|
318
|
+
} : {}
|
|
319
|
+
report_quote_candidate({
|
|
320
|
+
pool_address: poolAddr, pair: result.poolInfo.pair, dex_id: result.poolInfo.dex_id,
|
|
321
|
+
source: result.source || '', block_number: result.blockNumber,
|
|
322
|
+
ask_price: result.askQuote.price, bid_price: result.bidQuote.price, ...candidateTiers,
|
|
323
|
+
})
|
|
324
|
+
const quoteId = result.txid?.slice(0, 10) || `slot:${result.blockNumber}`
|
|
325
|
+
const tiersArg = isV2 ? { askTiers: result.depth?.ask?.tiers, bidTiers: result.depth?.bid?.tiers } : undefined
|
|
326
|
+
// Solana 地址 base58 大小写敏感,priceMap 用原地址 key(不 lowercase)
|
|
327
|
+
const t0a = result.poolInfo.tokenA?.address, t1a = result.poolInfo.tokenB?.address
|
|
328
|
+
const p0 = t0a ? Number(result.priceMap?.get(t0a)?.price) || 0 : 0
|
|
329
|
+
const p1 = t1a ? Number(result.priceMap?.get(t1a)?.price) || 0 : 0
|
|
330
|
+
this.quotePriceVerify.cacheQuote(
|
|
331
|
+
poolAddr, quoteId, result.source || '',
|
|
332
|
+
Number(result.askQuote.price), Number(result.bidQuote.price),
|
|
333
|
+
result.blockNumber, result.quote_amount_usd, p0, p1, tiersArg,
|
|
334
|
+
)
|
|
335
|
+
result.trace?.mark('verify'); result.trace?.flush()
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { AppConfig, CHAIN_ID, DEX_ID, log_warn, StandardPoolInfoType } from '@clonegod/ttd-core/dist'
|
|
2
|
+
import { Connection, PublicKey } from '@solana/web3.js'
|
|
3
|
+
import { subscribe_pool_account_update } from '../common/subscribe_account_update'
|
|
4
|
+
import { SolPoolEvent, toSolPoolEvent } from './pool_event'
|
|
5
|
+
import { TokenPriceCache } from './pricing/token_price_cache'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SolanaChainOps —— 链/VM 维能力(组合注入,对齐 BSC ChainOps/EvmChainOps、SUI SuiChainOps 的角色)。
|
|
9
|
+
*
|
|
10
|
+
* 把「询价流程不变、链能力可换」收成一个对象:同一套 AbstractDexQuote 模板,Solana 注入 SolanaChainOps。
|
|
11
|
+
* 与 SUI 的差异:
|
|
12
|
+
* - 池事件源是 stream-quote 的 **WS 通道**(subscribe_pool_account_update,port STREAM_WS_QUOTE_PORT),
|
|
13
|
+
* 推送的是**账户原始快照**(base64),非结构化 swap 事件(详见 pool_event.ts)。
|
|
14
|
+
* - assertProgramDeployed 用 connection.getAccountInfo + executable 校验(Solana 无 eth_getCode)。
|
|
15
|
+
* - Solana 地址是 base58 **大小写敏感**,loadTokenPrices 用原地址作 key(不可 lowercase)。
|
|
16
|
+
*/
|
|
17
|
+
export class SolanaChainOps {
|
|
18
|
+
readonly chainId = CHAIN_ID.SOLANA
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly appConfig: AppConfig,
|
|
22
|
+
private readonly connection: Connection,
|
|
23
|
+
private readonly tokenPriceCache: TokenPriceCache,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
/** Solana 地址 = base58 编码的 32 字节 pubkey;用 PublicKey 构造器校验最稳。 */
|
|
27
|
+
isValidPoolAddress(addr: string): boolean {
|
|
28
|
+
try {
|
|
29
|
+
// eslint-disable-next-line no-new
|
|
30
|
+
new PublicKey(addr)
|
|
31
|
+
return true
|
|
32
|
+
} catch {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 确认 program/账户确实部署在本链(对齐 BSC ChainOps.assertContractDeployed,抓"地址抄错/抄别链")。
|
|
39
|
+
* Solana = getAccountInfo + executable 校验。空串跳过。fail-loud(找不到/非可执行即抛)。
|
|
40
|
+
*/
|
|
41
|
+
async assertProgramDeployed(programAddress: string): Promise<void> {
|
|
42
|
+
if (!programAddress) return
|
|
43
|
+
const info = await this.connection.getAccountInfo(new PublicKey(programAddress))
|
|
44
|
+
if (!info) {
|
|
45
|
+
throw new Error(`[chain-ops] program ${programAddress} 在本链不存在(getAccountInfo=null)—— 疑似抄错地址/抄别链`)
|
|
46
|
+
}
|
|
47
|
+
if (!info.executable) {
|
|
48
|
+
throw new Error(`[chain-ops] account ${programAddress} 非 executable program —— 疑似把数据账户当 program`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 查 token USD 价(地址→{price})。供 depth/verify 的 USD 口径。
|
|
54
|
+
* ⚠ key 用**原始地址**(base58 大小写敏感,不能 lowercase)。
|
|
55
|
+
*/
|
|
56
|
+
async loadTokenPrices(addresses: string[]): Promise<Map<string, { price: string } | undefined>> {
|
|
57
|
+
const out = new Map<string, { price: string } | undefined>()
|
|
58
|
+
await Promise.all(addresses.map(async addr => {
|
|
59
|
+
try {
|
|
60
|
+
const p = await this.tokenPriceCache.getTokenPrice(addr)
|
|
61
|
+
out.set(addr, { price: p.price })
|
|
62
|
+
} catch {
|
|
63
|
+
out.set(addr, undefined)
|
|
64
|
+
}
|
|
65
|
+
}))
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 订阅新区块(v1 兜底触发)—— 消费 stream-block 的 new_block 通道。 */
|
|
70
|
+
subscribeNewBlock(handler: (raw: string) => void): void {
|
|
71
|
+
this.appConfig.arb_event_subscriber.subscribe_new_block(this.chainId, handler)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 订阅池子账户更新(v2 触发)—— 消费 stream-quote 的 WS 通道。
|
|
76
|
+
* subscribe_pool_account_update 内部 onOpen 时只 send 注册池,stream-quote 据此扇出;
|
|
77
|
+
* 这里把原始 wire 归一化为 SolPoolEvent 再回调。
|
|
78
|
+
*/
|
|
79
|
+
subscribePoolEvents(dexId: string, pools: StandardPoolInfoType[], handler: (evt: SolPoolEvent) => void): void {
|
|
80
|
+
const dex = dexId.toUpperCase() as DEX_ID
|
|
81
|
+
const poolSet = new Set(pools.map(p => p.pool_address))
|
|
82
|
+
subscribe_pool_account_update(dex, pools, (data) => {
|
|
83
|
+
try {
|
|
84
|
+
if (!poolSet.has(data.pool_address)) return
|
|
85
|
+
handler(toSolPoolEvent(data))
|
|
86
|
+
} catch (e) {
|
|
87
|
+
log_warn(`[SolanaChainOps] bad pool account update`, data)
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
}
|