@clonegod/ttd-sol-common 2.0.75 → 2.0.76

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.
@@ -1,4 +1,4 @@
1
1
  import { StandardPoolInfoType } from "@clonegod/ttd-core";
2
2
  import { DEX_ID } from "@clonegod/ttd-core/dist";
3
- import { SolanaPoolAccountUpdateEventData } from "../types";
4
- export declare function subscribe_pool_account_update(dex_id: DEX_ID, pool_list: StandardPoolInfoType[], callback: (eventData: SolanaPoolAccountUpdateEventData) => void): void;
3
+ import { SolanaPoolAccountUpdateEventData, SolanaSwapVerifyEventData } from "../types";
4
+ export declare function subscribe_pool_account_update(dex_id: DEX_ID, pool_list: StandardPoolInfoType[], callback: (eventData: SolanaPoolAccountUpdateEventData) => void, onSwapVerify?: (eventData: SolanaSwapVerifyEventData) => void): void;
@@ -2,7 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.subscribe_pool_account_update = subscribe_pool_account_update;
4
4
  const dist_1 = require("@clonegod/ttd-core/dist");
5
- function subscribe_pool_account_update(dex_id, pool_list, callback) {
5
+ function subscribe_pool_account_update(dex_id, pool_list, callback, onSwapVerify) {
6
6
  const port = dist_1.SERVICE_PORT.STREAM_QUOTE_WS;
7
7
  const ws_url = `ws://127.0.0.1:${port}`;
8
8
  const ws_client = new dist_1.WebSocketClient(ws_url);
@@ -16,11 +16,16 @@ function subscribe_pool_account_update(dex_id, pool_list, callback) {
16
16
  (0, dist_1.log_info)(`subscribe_pool_account_update: ACK ${eventData.ws_id || eventData.pool_address || ''}`);
17
17
  return;
18
18
  }
19
- if (!eventData.data) {
19
+ if (eventData.kind === 'swap_verify') {
20
+ onSwapVerify?.(eventData);
21
+ return;
22
+ }
23
+ const accountData = eventData;
24
+ if (!accountData.data) {
20
25
  (0, dist_1.log_warn)(`subscribe_pool_account_update: no data`, eventData);
21
26
  return;
22
27
  }
23
- callback(eventData);
28
+ callback(accountData);
24
29
  }));
25
30
  ws_client.connect();
26
31
  }
@@ -1,7 +1,7 @@
1
1
  import { AppConfig, QuoteDepthOutput, QuoteResultType, StandardPoolInfoType } from '@clonegod/ttd-core/dist';
2
2
  import { SolanaChainOps } from './chain_ops';
3
3
  import { SolPoolEvent } from './pool_event';
4
- import { QuotePriceVerify, CheckSwapParams } from './verify/quote_price_verify';
4
+ import { QuotePriceVerify } from './verify/quote_price_verify';
5
5
  import { QuoteTrace } from './quote_trace';
6
6
  export type ConsistencyPolicy = 'snapshot' | 'slot-gate' | 'tx-driven';
7
7
  export interface PoolInitSummary {
@@ -50,7 +50,7 @@ export declare abstract class AbstractDexQuote<C extends SolanaChainOps = Solana
50
50
  protected calculateDepth(_poolInfo: StandardPoolInfoType, _poolAddress: string, _priceMap: Map<string, {
51
51
  price: string;
52
52
  } | undefined>): Promise<QuoteDepthOutput | undefined>;
53
- protected buildSwapVerify(_poolInfo: StandardPoolInfoType, _evt: SolPoolEvent): CheckSwapParams | null;
53
+ protected readonly feeInVault: boolean;
54
54
  protected isStateConsistentForQuote(_poolInfo: StandardPoolInfoType): boolean;
55
55
  protected accountSlots: Map<string, number>;
56
56
  protected recordAccountSlot(accountAddr: string, slot: number): void;
@@ -58,6 +58,7 @@ export declare abstract class AbstractDexQuote<C extends SolanaChainOps = Solana
58
58
  protected beforeLoadPools(): Promise<void>;
59
59
  protected getQuoteAmountUsd(poolInfo: StandardPoolInfoType): number;
60
60
  private registerEventHandlers;
61
+ private handleSwapVerify;
61
62
  private assertValidBlockNumber;
62
63
  private isStaleUpdate;
63
64
  private shouldApplyState;
@@ -15,13 +15,13 @@ class AbstractDexQuote {
15
15
  this.latestBlockSlot = 0;
16
16
  this.MIN_QUOTE_INTERVAL_MS = Math.max(3000, parseInt(process.env.MIN_QUOTE_INTERVAL_MS || '10000', 10) || 10000);
17
17
  this.consistencyPolicy = 'snapshot';
18
+ this.feeInVault = true;
18
19
  this.accountSlots = new Map();
19
20
  this.appConfig = appConfig;
20
21
  this.chain = chain;
21
22
  }
22
23
  async refreshStateFromEvent(_poolInfo, _evt) { }
23
24
  async calculateDepth(_poolInfo, _poolAddress, _priceMap) { return undefined; }
24
- buildSwapVerify(_poolInfo, _evt) { return null; }
25
25
  isStateConsistentForQuote(_poolInfo) { return true; }
26
26
  recordAccountSlot(accountAddr, slot) {
27
27
  this.accountSlots.set(accountAddr, slot);
@@ -50,12 +50,34 @@ class AbstractDexQuote {
50
50
  registerEventHandlers() {
51
51
  this.chain.subscribeNewBlock((raw) => this.handleBlockUpdateEvent(raw));
52
52
  this.chain.subscribePoolEvents(this.dexId, Array.from(this.poolInfoMap.values()), async (evt) => {
53
- const { bundle, verifyLog } = await this.calculateQuote(evt);
53
+ const bundle = await this.calculateQuote(evt);
54
54
  if (bundle)
55
55
  this.publishQuote(bundle);
56
- if (verifyLog)
57
- (0, dist_1.log_info)(verifyLog);
58
- });
56
+ }, (sv) => this.handleSwapVerify(sv));
57
+ }
58
+ handleSwapVerify(sv) {
59
+ const poolInfo = this.poolInfoMap.get(sv.pool_address);
60
+ if (!poolInfo)
61
+ return;
62
+ const params = {
63
+ poolAddress: poolInfo.pool_address,
64
+ poolName: poolInfo.pool_name,
65
+ blockNumber: Number(sv.slot),
66
+ txHash: sv.tx_hash || 'swap',
67
+ amount0: sv.amount0,
68
+ amount1: sv.amount1,
69
+ token0Address: poolInfo.tokenA?.address,
70
+ token1Address: poolInfo.tokenB?.address,
71
+ token0Decimals: Number(poolInfo.tokenA?.decimals ?? 0),
72
+ token1Decimals: Number(poolInfo.tokenB?.decimals ?? 0),
73
+ poolInfo: { tokenA: poolInfo.tokenA, tokenB: poolInfo.tokenB, quote_token: poolInfo.quote_token },
74
+ swapperDeltaConvention: false,
75
+ feeExclusiveExec: !this.feeInVault,
76
+ feeRateBps: poolInfo.fee_rate,
77
+ };
78
+ const verifyLog = this.quotePriceVerify.checkSwap(params);
79
+ if (verifyLog)
80
+ (0, dist_1.log_info)(verifyLog);
59
81
  }
60
82
  assertValidBlockNumber(blockNumber, ctx) {
61
83
  if (!Number.isInteger(blockNumber) || blockNumber <= 0) {
@@ -76,7 +98,7 @@ class AbstractDexQuote {
76
98
  async handleBlockUpdateEvent(eventData) {
77
99
  const parsed = JSON.parse(eventData);
78
100
  const slot = Number(parsed.slot);
79
- const blockTime = Number(parsed.blockTime);
101
+ const streamRecvMs = Number(parsed.recvBlockTime) || Date.now();
80
102
  this.assertValidBlockNumber(slot, `${this.dexId} block-event`);
81
103
  this.latestBlockSlot = slot;
82
104
  for (const poolInfo of this.poolInfoMap.values()) {
@@ -85,7 +107,7 @@ class AbstractDexQuote {
85
107
  const last = this.poolLastQuoteTimeMap.get(poolAddress) || 0;
86
108
  if (now - last >= this.MIN_QUOTE_INTERVAL_MS) {
87
109
  this.poolLastQuoteTimeMap.set(poolAddress, now);
88
- const result = await this.calculateQuoteForPool(poolInfo, blockTime, slot);
110
+ const result = await this.calculateQuoteForPool(poolInfo, streamRecvMs, slot);
89
111
  if (result)
90
112
  this.publishQuote(result);
91
113
  }
@@ -94,23 +116,18 @@ class AbstractDexQuote {
94
116
  async calculateQuote(evt) {
95
117
  const poolInfo = this.poolInfoMap.get(evt.pool_address);
96
118
  if (!poolInfo)
97
- return { bundle: null };
119
+ return null;
98
120
  this.assertValidBlockNumber(evt.blockNumber, `${this.dexId} ${poolInfo.pool_name} account-update`);
99
- let verifyLog;
100
- const v = this.buildSwapVerify(poolInfo, evt);
101
- if (v)
102
- verifyLog = this.quotePriceVerify.checkSwap(v) || undefined;
103
121
  const hasWv = Number.isInteger(evt.writeVersion);
104
122
  const skipApply = hasWv && !this.shouldApplyState(evt.pool_address, evt.blockNumber, evt.writeVersion);
105
123
  if (!skipApply)
106
124
  await this.refreshStateFromEvent(poolInfo, evt);
107
125
  if (hasWv && this.isStaleUpdate(evt.pool_address, evt.blockNumber, evt.writeVersion))
108
- return { bundle: null, verifyLog };
126
+ return null;
109
127
  if (this.consistencyPolicy === 'slot-gate' && !this.isStateConsistentForQuote(poolInfo)) {
110
- return { bundle: null, verifyLog };
128
+ return null;
111
129
  }
112
- const bundle = await this.calculateQuoteForPool(poolInfo, evt.blockTime, evt.blockNumber, evt);
113
- return { bundle, verifyLog };
130
+ return this.calculateQuoteForPool(poolInfo, evt.blockTime, evt.blockNumber, evt);
114
131
  }
115
132
  async calculateQuoteForPool(poolInfo, streamTimestamp, blockNumber, evt) {
116
133
  const { pool_address } = poolInfo;
@@ -1,5 +1,6 @@
1
1
  import { AppConfig, CHAIN_ID, StandardPoolInfoType } from '@clonegod/ttd-core/dist';
2
2
  import { Connection } from '@solana/web3.js';
3
+ import { SolanaSwapVerifyEventData } from '../types';
3
4
  import { SolPoolEvent } from './pool_event';
4
5
  import { TokenPriceCache } from './pricing/token_price_cache';
5
6
  export declare class SolanaChainOps {
@@ -14,5 +15,5 @@ export declare class SolanaChainOps {
14
15
  price: string;
15
16
  } | undefined>>;
16
17
  subscribeNewBlock(handler: (raw: string) => void): void;
17
- subscribePoolEvents(dexId: string, pools: StandardPoolInfoType[], handler: (evt: SolPoolEvent) => void): void;
18
+ subscribePoolEvents(dexId: string, pools: StandardPoolInfoType[], handler: (evt: SolPoolEvent) => void, onSwapVerify?: (evt: SolanaSwapVerifyEventData) => void): void;
18
19
  }
@@ -48,7 +48,7 @@ class SolanaChainOps {
48
48
  subscribeNewBlock(handler) {
49
49
  this.appConfig.arb_event_subscriber.subscribe_new_block(this.chainId, handler);
50
50
  }
51
- subscribePoolEvents(dexId, pools, handler) {
51
+ subscribePoolEvents(dexId, pools, handler, onSwapVerify) {
52
52
  const dex = dexId.toUpperCase();
53
53
  const poolSet = new Set(pools.map(p => p.pool_address));
54
54
  (0, subscribe_account_update_1.subscribe_pool_account_update)(dex, pools, (data) => {
@@ -60,7 +60,18 @@ class SolanaChainOps {
60
60
  catch (e) {
61
61
  (0, dist_1.log_warn)(`[SolanaChainOps] bad pool account update`, data);
62
62
  }
63
- });
63
+ }, onSwapVerify
64
+ ? (sv) => {
65
+ try {
66
+ if (!poolSet.has(sv.pool_address))
67
+ return;
68
+ onSwapVerify(sv);
69
+ }
70
+ catch (e) {
71
+ (0, dist_1.log_warn)(`[SolanaChainOps] bad swap-verify event`, sv);
72
+ }
73
+ }
74
+ : undefined);
64
75
  }
65
76
  }
66
77
  exports.SolanaChainOps = SolanaChainOps;
@@ -46,7 +46,11 @@ async function unregisterPoolSubscriptions(appConfig, quote_app_id) {
46
46
  }
47
47
  }
48
48
  async function attachPoolSubscriptionLifecycle(appConfig, pools) {
49
- const quote_app_id = String(appConfig.env_args.app_name || '').toLowerCase() || 'sol-quote';
49
+ const quote_app_id = process.env.name || String(appConfig.env_args.app_name || '');
50
+ if (!quote_app_id) {
51
+ throw new Error('[pool/subscriptions] 无法确定 quote_app_id:process.env.name(PM2 进程名) 与 APP_NAME 均为空。'
52
+ + '询价进程必须由 PM2/trade-mgt 以确定进程名启动,否则订阅会被 reconcile 当孤儿清除。');
53
+ }
50
54
  await registerPoolSubscriptions(appConfig, pools, quote_app_id);
51
55
  let cleaned = false;
52
56
  const cleanup = async (sig) => {
@@ -16,6 +16,8 @@ export interface CheckSwapParams {
16
16
  quote_token: string;
17
17
  };
18
18
  swapperDeltaConvention?: boolean;
19
+ feeExclusiveExec?: boolean;
20
+ feeRateBps?: number;
19
21
  }
20
22
  export declare class QuotePriceVerify {
21
23
  private quoteCache;
@@ -47,6 +47,11 @@ function pickMatchingTier(tiers, actualAmountIn) {
47
47
  }
48
48
  return { tier: top, mode: 'extrapolated_high', lo_pct: top.pct, hi_pct: null };
49
49
  }
50
+ function stripFeeExclusive(refPrice, isBuy, r) {
51
+ if (!(r > 0) || r >= 1)
52
+ return refPrice;
53
+ return isBuy ? refPrice * (1 - r) : refPrice / (1 - r);
54
+ }
50
55
  class QuotePriceVerify {
51
56
  constructor() {
52
57
  this.quoteCache = new Map();
@@ -109,6 +114,9 @@ class QuotePriceVerify {
109
114
  if (!match)
110
115
  continue;
111
116
  refPrice = match.tier.price;
117
+ if (params.feeExclusiveExec && match.tier.amount_in > 0) {
118
+ refPrice = stripFeeExclusive(refPrice, isBuy, match.tier.fee / match.tier.amount_in);
119
+ }
112
120
  tierInfo = {
113
121
  matched_pct: parseFloat(match.tier.pct.toFixed(4)),
114
122
  mode: match.mode,
@@ -119,6 +127,9 @@ class QuotePriceVerify {
119
127
  }
120
128
  else {
121
129
  refPrice = isBuy ? cached.askPrice : cached.bidPrice;
130
+ if (params.feeExclusiveExec && params.feeRateBps && params.feeRateBps > 0) {
131
+ refPrice = stripFeeExclusive(refPrice, isBuy, params.feeRateBps / 10000);
132
+ }
122
133
  }
123
134
  if (refPrice <= 0)
124
135
  continue;
@@ -19,6 +19,21 @@ export interface SolanaAccountUpdateEvent {
19
19
  accountType: SolanaPoolAccountType;
20
20
  accountData?: Buffer | string;
21
21
  }
22
+ export interface SolanaSwapVerifyEventData {
23
+ kind: 'swap_verify';
24
+ provider_id: string;
25
+ event_time: number;
26
+ dex_id: string;
27
+ pool_address: string;
28
+ pool_name: string;
29
+ slot: number;
30
+ tx_index: number;
31
+ tx_hash: string;
32
+ block_time: number;
33
+ amount0: string;
34
+ amount1: string;
35
+ }
36
+ export declare const EVENT_POOL_SWAP_VERIFY = "EVENT_POOL_SWAP_VERIFY";
22
37
  export interface SolanaBlockMetaUpdateEvent {
23
38
  slot: number;
24
39
  blockHash: string;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SolanaPoolAccountType = void 0;
3
+ exports.EVENT_POOL_SWAP_VERIFY = exports.SolanaPoolAccountType = void 0;
4
4
  var SolanaPoolAccountType;
5
5
  (function (SolanaPoolAccountType) {
6
6
  SolanaPoolAccountType["POOL"] = "pool";
@@ -9,3 +9,4 @@ var SolanaPoolAccountType;
9
9
  SolanaPoolAccountType["TICK_ARRAY"] = "tickArray";
10
10
  SolanaPoolAccountType["BIN_ARRAY"] = "binArray";
11
11
  })(SolanaPoolAccountType || (exports.SolanaPoolAccountType = SolanaPoolAccountType = {}));
12
+ exports.EVENT_POOL_SWAP_VERIFY = 'EVENT_POOL_SWAP_VERIFY';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clonegod/ttd-sol-common",
3
- "version": "2.0.75",
3
+ "version": "2.0.76",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -13,7 +13,7 @@
13
13
  "push": "npm run build && npm publish"
14
14
  },
15
15
  "dependencies": {
16
- "@clonegod/ttd-core": "3.1.88",
16
+ "@clonegod/ttd-core": "3.1.89",
17
17
  "@solana/web3.js": "1.91.6",
18
18
  "rpc-websockets": "7.10.0",
19
19
  "axios": "1.17.0",
@@ -1,13 +1,22 @@
1
1
  import { StandardPoolInfoType } from "@clonegod/ttd-core";
2
2
  import { DEX_ID, log_info, log_warn, SERVICE_PORT, WebSocketClient } from "@clonegod/ttd-core/dist";
3
- import { SolanaPoolAccountUpdateEventData } from "../types";
3
+ import { SolanaPoolAccountUpdateEventData, SolanaSwapVerifyEventData } from "../types";
4
4
 
5
5
  /**
6
- * 集中订阅方式,丛 solana-stream 获取池子变化事件,获得poolRawData
6
+ * 集中订阅方式,丛 solana-stream 获取池子变化事件,获得poolRawData
7
7
  * - 询价:基于池子数据计算价格
8
8
  * - 交易使用:交易中需要池子的最新状态
9
+ *
10
+ * 同一条 WS 上承载两类消息(按 `kind` 判别):
11
+ * - 账户快照(无 kind)→ callback(刷新本地状态算价)
12
+ * - swap-verify(kind:'swap_verify')→ onSwapVerify(对账报价偏差)
9
13
  */
10
- export function subscribe_pool_account_update(dex_id: DEX_ID, pool_list: StandardPoolInfoType[], callback: (eventData: SolanaPoolAccountUpdateEventData) => void) {
14
+ export function subscribe_pool_account_update(
15
+ dex_id: DEX_ID,
16
+ pool_list: StandardPoolInfoType[],
17
+ callback: (eventData: SolanaPoolAccountUpdateEventData) => void,
18
+ onSwapVerify?: (eventData: SolanaSwapVerifyEventData) => void,
19
+ ) {
11
20
  const port = SERVICE_PORT.STREAM_QUOTE_WS;
12
21
  const ws_url = `ws://127.0.0.1:${port}`;
13
22
  const ws_client = new WebSocketClient(ws_url)
@@ -16,17 +25,23 @@ export function subscribe_pool_account_update(dex_id: DEX_ID, pool_list: Standar
16
25
  ws_client.send(JSON.stringify({ dex_id, pool_name, pool_address }))
17
26
  })
18
27
  })
19
- ws_client.onMessage(((eventData: SolanaPoolAccountUpdateEventData): void => {
28
+ ws_client.onMessage(((eventData: SolanaPoolAccountUpdateEventData | SolanaSwapVerifyEventData): void => {
20
29
  // 订阅 ACK(stream-quote 确认订阅成功,无 data 字段)→ 打 info 确认链路通,不当异常告警
21
30
  if ((eventData as any).type === 'subscribed') {
22
31
  log_info(`subscribe_pool_account_update: ACK ${(eventData as any).ws_id || (eventData as any).pool_address || ''}`);
23
32
  return;
24
33
  }
25
- if (!eventData.data) {
34
+ // swap-verify 消息:pool-side vault Δ,交给 onSwapVerify 对账(必须早于 !data 判定,本消息无 data 字段)
35
+ if ((eventData as any).kind === 'swap_verify') {
36
+ onSwapVerify?.(eventData as SolanaSwapVerifyEventData);
37
+ return;
38
+ }
39
+ const accountData = eventData as SolanaPoolAccountUpdateEventData;
40
+ if (!accountData.data) {
26
41
  log_warn(`subscribe_pool_account_update: no data`, eventData);
27
42
  return;
28
43
  }
29
- callback(eventData);
44
+ callback(accountData);
30
45
  }));
31
46
  ws_client.connect();
32
47
  }
@@ -2,7 +2,7 @@ import {
2
2
  AppConfig, QuoteDepthOutput, QuoteResultType, StandardPoolInfoType,
3
3
  log_info, log_warn, on_quote_response, report_quote_candidate,
4
4
  } from '@clonegod/ttd-core/dist'
5
- import { SolanaBlockMetaUpdateEvent } from '../types'
5
+ import { SolanaBlockMetaUpdateEvent, SolanaSwapVerifyEventData } from '../types'
6
6
  import { SolanaChainOps } from './chain_ops'
7
7
  import { SolPoolEvent } from './pool_event'
8
8
  import { QuotePriceVerify, CheckSwapParams } from './verify/quote_price_verify'
@@ -15,7 +15,9 @@ import { QuoteTrace } from './quote_trace'
15
15
  // 因此去掉 SUI 的 swap/add/remove 事件分类(debouncedEventTypes)。
16
16
  // · 单调水位线用 Solana 账户 writeVersion(= txIndex 对等物);wire 暂未带 → 缺省按"无序号"处理。
17
17
  // · 无 v3 PriceFeed(同 SUI,省 do_quote_v3)。
18
- // · verify swap-amount 对账暂缓(账户快照不含 amount)→ buildSwapVerify 默认 null;cacheQuote 跨源仍生效。
18
+ // · verify(报价偏差)走**独立的 swap-verify 通道**(非账户快照):stream-quote 从真实交易的
19
+ // pre/postTokenBalances 解码订阅池子 vault Δ(DEX 无关)→ WS push → handleSwapVerify → checkSwap。
20
+ // exec_price = |Δquote/Δbase|(pool-side vault Δ),fee-exclusive 对账(费路由出池的 DEX 由 feeInVault=false 触发剥费)。
19
21
 
20
22
  /**
21
23
  * 流动性一致性策略(设计文档 §2.2 定稿:3 订阅原型)。各 DEX 子类按协议特性声明:
@@ -111,8 +113,13 @@ export abstract class AbstractDexQuote<C extends SolanaChainOps = SolanaChainOps
111
113
  protected async refreshStateFromEvent(_poolInfo: StandardPoolInfoType, _evt: SolPoolEvent): Promise<void> {}
112
114
  /** 深度(tier 化);fee 来自链上 load。priceMap 由基类预取。默认 undefined(step 3 各 DEX 逐档实现)。 */
113
115
  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
+ * verify 口径:池子手续费是否留在 vault 内。
118
+ * - true(默认;Raydium/Orca/Meteora 等):fee 累进储备 → vault Δ 含费 → 与含费报价同口径,不剥费。
119
+ * - false(pumpswap:protocol/creator fee 路由出池到独立账户):vault Δ 不含费 → checkSwap 把参考价剥成 fee-exclusive。
120
+ * 详见 quote_price_verify CheckSwapParams.feeExclusiveExec。
121
+ */
122
+ protected readonly feeInVault: boolean = true
116
123
 
117
124
  /**
118
125
  * slot 门控钩子(仅 consistencyPolicy='slot-gate' 生效):判断本池"走单触碰的多账户"状态是否 slot 一致。
@@ -160,12 +167,43 @@ export abstract class AbstractDexQuote<C extends SolanaChainOps = SolanaChainOps
160
167
  // v1 兜底:new_block 通道(stream-block)
161
168
  this.chain.subscribeNewBlock((raw) => this.handleBlockUpdateEvent(raw))
162
169
  // 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
- })
170
+ this.chain.subscribePoolEvents(
171
+ this.dexId,
172
+ Array.from(this.poolInfoMap.values()),
173
+ async (evt: SolPoolEvent) => {
174
+ const bundle = await this.calculateQuote(evt)
175
+ if (bundle) this.publishQuote(bundle)
176
+ },
177
+ // swap-verify 通道(链上真实成交 vault Δ):独立于账户快照,对账缓存报价 → [Verify]
178
+ (sv: SolanaSwapVerifyEventData) => this.handleSwapVerify(sv),
179
+ )
180
+ }
181
+
182
+ /**
183
+ * 消费 stream-quote 推来的 swap-verify(pool-side vault Δ)→ checkSwap 对账缓存报价 → 打 [Verify]。
184
+ * exec_price 由 |Δquote/Δbase| 算;fee-exclusive 口径由 feeInVault 决定(详见 CheckSwapParams.feeExclusiveExec)。
185
+ */
186
+ private handleSwapVerify(sv: SolanaSwapVerifyEventData): void {
187
+ const poolInfo = this.poolInfoMap.get(sv.pool_address)
188
+ if (!poolInfo) return
189
+ const params: CheckSwapParams = {
190
+ poolAddress: poolInfo.pool_address,
191
+ poolName: poolInfo.pool_name,
192
+ blockNumber: Number(sv.slot),
193
+ txHash: sv.tx_hash || 'swap',
194
+ amount0: sv.amount0,
195
+ amount1: sv.amount1,
196
+ token0Address: poolInfo.tokenA?.address,
197
+ token1Address: poolInfo.tokenB?.address,
198
+ token0Decimals: Number(poolInfo.tokenA?.decimals ?? 0),
199
+ token1Decimals: Number(poolInfo.tokenB?.decimals ?? 0),
200
+ poolInfo: { tokenA: poolInfo.tokenA, tokenB: poolInfo.tokenB, quote_token: poolInfo.quote_token },
201
+ swapperDeltaConvention: false, // amount0/1 是 pool-side vault Δ
202
+ feeExclusiveExec: !this.feeInVault, // 费路由出池(pumpswap)→ 剥参考价的费
203
+ feeRateBps: poolInfo.fee_rate, // market-data 强制整数 bps
204
+ }
205
+ const verifyLog = this.quotePriceVerify.checkSwap(params)
206
+ if (verifyLog) log_info(verifyLog)
169
207
  }
170
208
 
171
209
  // ════════════ 事件处理(基类)════════════
@@ -192,9 +230,11 @@ export abstract class AbstractDexQuote<C extends SolanaChainOps = SolanaChainOps
192
230
 
193
231
  private async handleBlockUpdateEvent(eventData: string): Promise<void> {
194
232
  const parsed = JSON.parse(eventData) as SolanaBlockMetaUpdateEvent
195
- // slot/blockTime 可能是 protobuf u64 字符串 → 在"blockNumber"归一化边界统一转 number
233
+ // slot 可能是 protobuf u64 字符串 → 在"blockNumber"归一化边界统一转 number
196
234
  const slot = Number(parsed.slot)
197
- const blockTime = Number(parsed.blockTime)
235
+ // streamTimestamp 用本地接收 ms(recvBlockTime),与 v2 的 event_time(ms) 同单位;
236
+ // 链上 blockTime 是秒,直接当时间戳会让 analyze 延迟列混入绝对秒值(与 ms 相减失真)
237
+ const streamRecvMs = Number(parsed.recvBlockTime) || Date.now()
198
238
  this.assertValidBlockNumber(slot, `${this.dexId} block-event`)
199
239
  this.latestBlockSlot = slot // v1 兜底询价取当前 slot 用(动态费 currentPoint)
200
240
  for (const poolInfo of this.poolInfoMap.values()) {
@@ -203,21 +243,18 @@ export abstract class AbstractDexQuote<C extends SolanaChainOps = SolanaChainOps
203
243
  const last = this.poolLastQuoteTimeMap.get(poolAddress) || 0
204
244
  if (now - last >= this.MIN_QUOTE_INTERVAL_MS) {
205
245
  this.poolLastQuoteTimeMap.set(poolAddress, now) // await 前占位,防节流被打穿
206
- const result = await this.calculateQuoteForPool(poolInfo, blockTime, slot)
246
+ const result = await this.calculateQuoteForPool(poolInfo, streamRecvMs, slot)
207
247
  if (result) this.publishQuote(result)
208
248
  }
209
249
  }
210
250
  }
211
251
 
212
- private async calculateQuote(evt: SolPoolEvent): Promise<{ bundle: QuoteBundle | null, verifyLog?: string }> {
252
+ private async calculateQuote(evt: SolPoolEvent): Promise<QuoteBundle | null> {
213
253
  const poolInfo = this.poolInfoMap.get(evt.pool_address)
214
- if (!poolInfo) return { bundle: null }
254
+ if (!poolInfo) return null
215
255
  this.assertValidBlockNumber(evt.blockNumber, `${this.dexId} ${poolInfo.pool_name} account-update`)
216
256
 
217
- // swap verify(唯一入口)。SOL 账户快照默认无 swap amount buildSwapVerify 返回 null 即跳过。
218
- let verifyLog: string | undefined
219
- const v = this.buildSwapVerify(poolInfo, evt)
220
- if (v) verifyLog = this.quotePriceVerify.checkSwap(v) || undefined
257
+ // 注:报价偏差 verify 不在此(账户快照不含 swap amount),走独立 swap-verify 通道(handleSwapVerify)。
221
258
 
222
259
  // 状态更新:每个账户快照都做(不受报价 staleness 短路影响)。
223
260
  // 例外:乱序晚到的旧 writeVersion 跳过,防状态回拉(writeVersion 缺省时无条件应用)。
@@ -226,15 +263,14 @@ export abstract class AbstractDexQuote<C extends SolanaChainOps = SolanaChainOps
226
263
  if (!skipApply) await this.refreshStateFromEvent(poolInfo, evt)
227
264
 
228
265
  // compute 前短路:已陈旧 → 跳过重算+推送(状态已更新)
229
- if (hasWv && this.isStaleUpdate(evt.pool_address, evt.blockNumber, evt.writeVersion as number)) return { bundle: null, verifyLog }
266
+ if (hasWv && this.isStaleUpdate(evt.pool_address, evt.blockNumber, evt.writeVersion as number)) return null
230
267
 
231
268
  // slot 门控(slot-gate 协议):走单触碰的多账户 slot 不一致 → 本拍不报价,等落后账户的下一条更新重触发
232
269
  if (this.consistencyPolicy === 'slot-gate' && !this.isStateConsistentForQuote(poolInfo)) {
233
- return { bundle: null, verifyLog }
270
+ return null
234
271
  }
235
272
 
236
- const bundle = await this.calculateQuoteForPool(poolInfo, evt.blockTime, evt.blockNumber, evt)
237
- return { bundle, verifyLog }
273
+ return this.calculateQuoteForPool(poolInfo, evt.blockTime, evt.blockNumber, evt)
238
274
  }
239
275
 
240
276
  private async calculateQuoteForPool(poolInfo: StandardPoolInfoType, streamTimestamp: number, blockNumber: number, evt?: SolPoolEvent): Promise<QuoteBundle | null> {
@@ -1,6 +1,7 @@
1
1
  import { AppConfig, CHAIN_ID, DEX_ID, log_warn, StandardPoolInfoType } from '@clonegod/ttd-core/dist'
2
2
  import { Connection, PublicKey } from '@solana/web3.js'
3
3
  import { subscribe_pool_account_update } from '../common/subscribe_account_update'
4
+ import { SolanaSwapVerifyEventData } from '../types'
4
5
  import { SolPoolEvent, toSolPoolEvent } from './pool_event'
5
6
  import { TokenPriceCache } from './pricing/token_price_cache'
6
7
 
@@ -75,17 +76,38 @@ export class SolanaChainOps {
75
76
  * 订阅池子账户更新(v2 触发)—— 消费 stream-quote 的 WS 通道。
76
77
  * subscribe_pool_account_update 内部 onOpen 时只 send 注册池,stream-quote 据此扇出;
77
78
  * 这里把原始 wire 归一化为 SolPoolEvent 再回调。
79
+ *
80
+ * onSwapVerify(可选):同一 WS 上的 swap-verify 消息(链上真实成交 vault Δ),用于报价偏差对账。
78
81
  */
79
- subscribePoolEvents(dexId: string, pools: StandardPoolInfoType[], handler: (evt: SolPoolEvent) => void): void {
82
+ subscribePoolEvents(
83
+ dexId: string,
84
+ pools: StandardPoolInfoType[],
85
+ handler: (evt: SolPoolEvent) => void,
86
+ onSwapVerify?: (evt: SolanaSwapVerifyEventData) => void,
87
+ ): void {
80
88
  const dex = dexId.toUpperCase() as DEX_ID
81
89
  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
+ subscribe_pool_account_update(
91
+ dex,
92
+ pools,
93
+ (data) => {
94
+ try {
95
+ if (!poolSet.has(data.pool_address)) return
96
+ handler(toSolPoolEvent(data))
97
+ } catch (e) {
98
+ log_warn(`[SolanaChainOps] bad pool account update`, data)
99
+ }
100
+ },
101
+ onSwapVerify
102
+ ? (sv) => {
103
+ try {
104
+ if (!poolSet.has(sv.pool_address)) return
105
+ onSwapVerify(sv)
106
+ } catch (e) {
107
+ log_warn(`[SolanaChainOps] bad swap-verify event`, sv)
108
+ }
109
+ }
110
+ : undefined,
111
+ )
90
112
  }
91
113
  }
@@ -18,8 +18,9 @@ import { SolanaPoolAccountUpdateEventData } from '../types'
18
18
  * AMM 聚合快照取 pool+vaultA+vaultB 三账户的 max writeVersion。
19
19
  * (writeVersion 缺省时基类回退"无序号无条件推",对齐 SUI 缺 txIndex 行为,防御性兜底。)
20
20
  *
21
- * verify(swap amount 对账)暂缓:SOL 账户快照不含 swap amount,buildSwapVerify 默认返回 null,
22
- * stream-quote 解码 swap amount 后再接(设计文档 §2.4 / verify 跨源 cacheQuote 仍生效)。
21
+ * verify(swap amount 对账)走**独立的 swap-verify 通道**(不在账户快照里):stream-quote 从真实交易
22
+ * pre/postTokenBalances 解码订阅池子 vault Δ WS push(SolanaSwapVerifyEventData)→ 询价进程 checkSwap。
23
+ * 故 SolPoolEvent 仍只承载状态快照,不带 swap amount(详见 abstract_dex_quote.handleSwapVerify)。
23
24
  */
24
25
  export interface SolPoolEvent {
25
26
  pool_address: string
@@ -62,10 +62,16 @@ export async function unregisterPoolSubscriptions(appConfig: AppConfig, quote_ap
62
62
 
63
63
  /**
64
64
  * 询价进程启动后调用一次:确保 helius provider + 注册全部池子 + 装退出时自动 unregister。
65
- * quote_app_id env_args.app_name(每个 DEX 询价进程唯一)。
65
+ * quote_app_id **必须 = PM2 进程名**(pm2 注入 process.env.name),与 trade-mgt register/reconcile 用的 app_id 一致。
66
+ * 否则 trade-mgt 的 60s reconcile 按 PM2 进程名核对,会把本进程自注册的订阅当"孤儿"注销 → 订阅消失、收不到事件。
67
+ * **取不到则 fail-loud(绝不兜底)**:塌成 'sol-quote' 这类 PM2 里不存在的名字 = 必被 reconcile 清掉,等于没注册还掩盖问题。
66
68
  */
67
69
  export async function attachPoolSubscriptionLifecycle(appConfig: AppConfig, pools: StandardPoolInfoType[]): Promise<void> {
68
- const quote_app_id = String(appConfig.env_args.app_name || '').toLowerCase() || 'sol-quote'
70
+ const quote_app_id = process.env.name || String(appConfig.env_args.app_name || '')
71
+ if (!quote_app_id) {
72
+ throw new Error('[pool/subscriptions] 无法确定 quote_app_id:process.env.name(PM2 进程名) 与 APP_NAME 均为空。'
73
+ + '询价进程必须由 PM2/trade-mgt 以确定进程名启动,否则订阅会被 reconcile 当孤儿清除。')
74
+ }
69
75
  await registerPoolSubscriptions(appConfig, pools, quote_app_id)
70
76
 
71
77
  let cleaned = false
@@ -119,6 +119,20 @@ function pickMatchingTier(tiers: QuoteTier[], actualAmountIn: number): TierMatch
119
119
  return { tier: top, mode: 'extrapolated_high', lo_pct: top.pct, hi_pct: null };
120
120
  }
121
121
 
122
+ /**
123
+ * 把含费报价价剥成 fee-exclusive(曲线价),与"费路由出池"DEX(pumpswap)的 vault Δ exec 同口径。
124
+ *
125
+ * 关键:费总是把 ask/bid **向两侧各撑开** r(曲线价 M 在中间),方向**不对称**:
126
+ * - ask(买基础币) = M / (1 − r) → ask_excl = ask × (1 − r)
127
+ * - bid(卖基础币) = M × (1 − r) → bid_excl = bid / (1 − r) ← 是除,不是乘!
128
+ * 不论费记输入侧还是输出侧(pumpswap 实测两种都有),上式都成立(逐项核对见 session memory)。
129
+ * ask/bid 同为 quote/base 口径(calculateStandardPrice),仅方向决定撑开符号。
130
+ */
131
+ function stripFeeExclusive(refPrice: number, isBuy: boolean, r: number): number {
132
+ if (!(r > 0) || r >= 1) return refPrice;
133
+ return isBuy ? refPrice * (1 - r) : refPrice / (1 - r);
134
+ }
135
+
122
136
  export interface CheckSwapParams {
123
137
  poolAddress: string;
124
138
  poolName: string;
@@ -142,6 +156,19 @@ export interface CheckSwapParams {
142
156
  * V4 caller 必须传 true,否则 side 会被算反(参见 tx 0xea45784b... / 0xdae0faa7... 验证)。
143
157
  */
144
158
  swapperDeltaConvention?: boolean;
159
+ /**
160
+ * fee-exclusive 对账口径(Solana pool-side vault Δ 用)。
161
+ *
162
+ * 背景:exec_price 由 **pool vault Δ** 算(Δquote/Δbase)。vault Δ 是否含费**因 DEX 而异**:
163
+ * - 费留池内(Raydium/Orca/Meteora 等):vault Δ 含费 → 与"含费 tier 价"同口径 → 不剥(false,默认)。
164
+ * - 费路由出池(pumpswap protocol/creator fee 进独立账户):vault Δ 不含费 → 须把参考价剥成 fee-exclusive
165
+ * 才与 vault Δ 同口径(否则系统性偏差≈费率)。
166
+ * 为 true 时:refPrice ×= (amount_in − fee)/amount_in(tier 模式,用该档自身的含费/费额);
167
+ * scalar 兜底用 (1 − feeRateBps/1e4) 一阶近似。
168
+ */
169
+ feeExclusiveExec?: boolean;
170
+ /** 池子费率(bps);feeExclusiveExec 且 scalar 兜底(无 tier)时用作一阶剥费因子。 */
171
+ feeRateBps?: number;
145
172
  }
146
173
 
147
174
  // ========== 主类 ==========
@@ -276,6 +303,11 @@ export class QuotePriceVerify {
276
303
  const match = pickMatchingTier(tiers, swapData.inputAmountUi);
277
304
  if (!match) continue;
278
305
  refPrice = match.tier.price;
306
+ // fee-exclusive 对账:把含费 tier 价剥成 fee-exclusive,与 fee-out-of-vault DEX(pumpswap)的 vault Δ 同口径。
307
+ // r = 该档费率(fee/amount_in,amount_in 含费)。
308
+ if (params.feeExclusiveExec && match.tier.amount_in > 0) {
309
+ refPrice = stripFeeExclusive(refPrice, isBuy, match.tier.fee / match.tier.amount_in);
310
+ }
279
311
  tierInfo = {
280
312
  matched_pct: parseFloat(match.tier.pct.toFixed(4)),
281
313
  mode: match.mode,
@@ -286,6 +318,10 @@ export class QuotePriceVerify {
286
318
  } else {
287
319
  // scalar fallback:旧 DEX 包未传 tiers 时使用
288
320
  refPrice = isBuy ? cached.askPrice : cached.bidPrice;
321
+ // scalar 无 tier 费额 → 用池子费率一阶剥费(feeExclusiveExec 时)
322
+ if (params.feeExclusiveExec && params.feeRateBps && params.feeRateBps > 0) {
323
+ refPrice = stripFeeExclusive(refPrice, isBuy, params.feeRateBps / 10000);
324
+ }
289
325
  }
290
326
 
291
327
  if (refPrice <= 0) continue;
@@ -34,6 +34,38 @@ export interface SolanaAccountUpdateEvent {
34
34
  accountData?: Buffer | string; // 账户原始数据
35
35
  }
36
36
 
37
+ /**
38
+ * 链上 swap 的 **pool-side vault Δ** 事件(verify 报价偏差用)。
39
+ *
40
+ * 由 stream-quote 从真实交易的 pre/postTokenBalances 解码订阅池子 vaultA/vaultB 的余额差,
41
+ * 经 WS push(与账户快照同通道,按 dex_id_pool_name_pool_address 路由)推给询价进程,
42
+ * 询价进程用它对账缓存报价(exec_price = |Δquote/Δbase|,fee-exclusive 口径,见 quote_price_verify)。
43
+ *
44
+ * 与 SolanaPoolAccountUpdateEventData 的区别:
45
+ * - 后者是**账户最新快照**(base64,用于刷新本地状态算价);
46
+ * - 本结构是**一笔 swap 的成交量**(vault Δ,用于验证报价准不准)。
47
+ * - 用 `kind:'swap_verify'` 判别(账户快照消息无 kind 字段)。
48
+ */
49
+ export interface SolanaSwapVerifyEventData {
50
+ kind: 'swap_verify'
51
+ provider_id: string
52
+ event_time: number // stream 本地收到 tx 的 ms(新鲜度/延迟用)
53
+ dex_id: string
54
+ pool_address: string
55
+ pool_name: string
56
+ slot: number // swap 所在 slot(= verify 的 swap_block)
57
+ tx_index: number // 块内交易序号
58
+ tx_hash: string
59
+ block_time: number // 链上出块时间 ms;0=未知,由消费端处理
60
+ /** vaultA(base/token0) 余额 Δ;pool-side signed(>0=流入池子);raw(含 decimals) */
61
+ amount0: string
62
+ /** vaultB(quote/token1) 余额 Δ;pool-side signed */
63
+ amount1: string
64
+ }
65
+
66
+ /** swap-verify 事件总线名(stream-quote 内 appConfig.emit → ws_push_server 路由)。 */
67
+ export const EVENT_POOL_SWAP_VERIFY = 'EVENT_POOL_SWAP_VERIFY'
68
+
37
69
  /**
38
70
  * Solana 区块元数据更新事件
39
71
  */