@clonegod/ttd-base-send-tx 0.1.1

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.
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TransactionSender = void 0;
4
+ const ttd_core_1 = require("@clonegod/ttd-core");
5
+ const channels_1 = require("./channels");
6
+ /**
7
+ * BASE 链 trader 端 TransactionSender
8
+ *
9
+ * ## 与 BSC 的差异
10
+ *
11
+ * BSC:3 builder bundle 并发(BlockRazor / 48Club / BLXR),主 tx + 3 tip tx 并行签名,bundle 投递
12
+ * BASE:无原生 bundle 通道;私有路由商(BlockRazor / bloXroute)走单笔 `eth_sendRawTransaction`
13
+ *
14
+ * ## 发送策略(fan-out 多端并发)
15
+ *
16
+ * 同一笔签名好的 raw tx 并发提交到所有启用的通道,先到的成功,
17
+ * 后到的同 nonce/同 signature 在 sequencer / mempool 端自然去重。
18
+ *
19
+ * - `direct_rpc`:直发 BASE sequencer(endpoint = 通用 `RPC_ENDPOINT`,mainnet.base.org / 自家 endpoint,可启用 MEV Protection)
20
+ * - `backup_rpc`:可选第二个 direct_rpc(`BACKUP_RPC_ENDPOINT`,不同 provider 做 RPC 故障冗余)
21
+ * - `blockrazor`:BlockRazor BASE Fast(强制 tip,调用方需在 tx 中含 tip transfer 或抬高 priorityFee)
22
+ * - `bloxroute`:bloXroute `blxr_tx`(可选 BackRunMe 回扣)
23
+ *
24
+ * ## ⚠️ BASE 上不存在 revert protection
25
+ *
26
+ * 失败的 tx **会**上链并扣 gas(跟 ETH 主网 Flashbots Protect 不同)。
27
+ * → 调用方必须先做链下模拟(eth_call / estimateGas),保证 tx 必成功才发
28
+ */
29
+ class TransactionSender {
30
+ constructor(appConfig) {
31
+ this.channels = [];
32
+ const env = appConfig.env_args;
33
+ if (env.send_tx_direct_rpc) {
34
+ this.channels.push(new channels_1.DirectRpcChannel(env.rpc_endpoint || 'https://mainnet.base.org'));
35
+ }
36
+ if (env.send_tx_backup_rpc && env.backup_rpc_endpoint) {
37
+ this.channels.push(new channels_1.DirectRpcChannel(env.backup_rpc_endpoint));
38
+ }
39
+ if (env.send_tx_blockrazor) {
40
+ if (!env.blockrazor_auth_token) {
41
+ (0, ttd_core_1.log_warn)('[TransactionSender] SEND_TX_BLOCKRAZOR=true 但 BLOCKRAZOR_AUTH_TOKEN 未配置,跳过 BlockRazor 通道');
42
+ }
43
+ else {
44
+ this.channels.push(new channels_1.BlockRazorChannel(env.blockrazor_endpoint, env.blockrazor_auth_token));
45
+ }
46
+ }
47
+ if (env.send_tx_bloxroute) {
48
+ if (!env.bloxroute_auth_token) {
49
+ (0, ttd_core_1.log_warn)('[TransactionSender] SEND_TX_BLOXROUTE=true 但 BLOXROUTE_AUTH_TOKEN 未配置,跳过 bloXroute 通道');
50
+ }
51
+ else {
52
+ this.channels.push(new channels_1.BloxrouteChannel({
53
+ url: env.bloxroute_endpoint,
54
+ authToken: env.bloxroute_auth_token,
55
+ backrunmeRewardAddress: env.bloxroute_backrunme_address || undefined,
56
+ }));
57
+ }
58
+ }
59
+ this.timeoutMs = env.send_tx_timeout_ms || 3000;
60
+ if (this.channels.length === 0) {
61
+ // 兜底:起码开主通道,避免空 sender 静默吞 tx
62
+ (0, ttd_core_1.log_warn)('[TransactionSender] 没有启用任何发送通道,回退到默认 direct_rpc');
63
+ this.channels.push(new channels_1.DirectRpcChannel(env.rpc_endpoint || 'https://mainnet.base.org'));
64
+ }
65
+ (0, ttd_core_1.log_info)(`[TransactionSender] BASE 启动,通道=${this.channels.map(c => c.name).join(',')}, timeout=${this.timeoutMs}ms`);
66
+ // 预热所有通道(fire-and-forget,不阻塞构造)。建立 TCP+TLS 连接复用给后续 sendTransaction,
67
+ // 第一笔真实交易免握手。
68
+ for (const ch of this.channels) {
69
+ ch.warmup?.().catch(() => { });
70
+ }
71
+ }
72
+ /**
73
+ * @param signedMainTx hex-encoded 主交易(无 tip 版本,发给非 tip 通道)
74
+ * @param channelTipTxMap channel name → 已签名 tx(同 nonce 的"含 tip"版本)
75
+ * 举例:{ 'blockrazor' => withTipSignedTx }
76
+ * 不在 map 中的 channel 收到 signedMainTx;在 map 中的 channel 收到对应 tx。
77
+ * Ethereum nonce 唯一性保证链上只有一笔执行,多通道并发安全。
78
+ * @param order_trace_id 订单追踪 id(日志用)
79
+ * @param pair 交易对名(日志用)
80
+ * @param _only_bundle BASE 忽略(无 bundle 概念)
81
+ * @param trace 可选 TradeTrace
82
+ */
83
+ async sendTransaction(signedMainTx, channelTipTxMap, order_trace_id, pair = '', _only_bundle = false, trace) {
84
+ trace?.mark('send_start');
85
+ const sends = this.channels.map(ch => {
86
+ const tx = channelTipTxMap?.get(ch.name) ?? signedMainTx;
87
+ return ch.send(tx, this.timeoutMs);
88
+ });
89
+ const results = await Promise.all(sends);
90
+ trace?.mark('send_done');
91
+ const ok = results.filter(r => !!r.txHash);
92
+ const fail = results.filter(r => !!r.error);
93
+ for (const r of ok) {
94
+ (0, ttd_core_1.log_info)(`[send] ✅ channel=${r.channel} trace=${order_trace_id} pair=${pair} ${r.elapsedMs}ms tx=${r.txHash}`);
95
+ }
96
+ for (const r of fail) {
97
+ (0, ttd_core_1.log_warn)(`[send] ❌ channel=${r.channel} trace=${order_trace_id} pair=${pair} ${r.elapsedMs}ms err=${r.error}`);
98
+ }
99
+ if (ok.length === 0) {
100
+ throw new Error(`所有 send 通道失败: ${fail.map(f => `${f.channel}=${f.error}`).join(' | ')}`);
101
+ }
102
+ return results.map(r => r.txHash ? `${r.channel}: ${r.txHash}` : `${r.channel}: ERR ${r.error}`);
103
+ }
104
+ }
105
+ exports.TransactionSender = TransactionSender;
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@clonegod/ttd-base-send-tx",
3
+ "version": "0.1.1",
4
+ "description": "BASE send-tx 服务 + trader 端 SDK (Phase 7 待实现 — 目前为 stub)",
5
+ "license": "UNLICENSED",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "clean": "rm -rf dist node_modules",
10
+ "build": "tsc --outDir ./dist",
11
+ "start": "node dist/index.js",
12
+ "push": "npm run build && npm publish"
13
+ },
14
+ "dependencies": {
15
+ "@clonegod/ttd-core": "3.1.80",
16
+ "@clonegod/ttd-base-common": "1.1.8",
17
+ "dotenv": "^16.4.7",
18
+ "ethers": "^5.8.0",
19
+ "undici": "^6.25.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^22.13.10",
23
+ "typescript": "^5.8.2"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ }
28
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * BlockRazor BASE Fast 通道
3
+ *
4
+ * - 文档:https://blockrazor.gitbook.io/blockrazor/tc/transaction-submission/fast/base
5
+ * - 协议:HTTP POST `eth_sendRawTransaction`(标准 JSON-RPC),与 direct_rpc 同形态
6
+ * - 认证:`Authorization: Bearer <token>` header
7
+ * - Endpoint:`http://base-fast.blockrazor.io`(默认)
8
+ * - 路由:私有 mempool + Speed Boost 转发给 sequencer
9
+ *
10
+ * ## 强制 tip 要求
11
+ *
12
+ * BlockRazor BASE Fast **强制**每笔 tx 给 tip:
13
+ * 方式 A:tx 内含 transfer ≥ 0.000003 ETH 到 `0x9D70AC39166ca154307a93fa6b595CF7962fe8e5`
14
+ * 方式 B:`maxPriorityFeePerGas` ≥ 估算成本的 5%
15
+ *
16
+ * **本通道只负责转发已签名 raw tx**。调用方(trade encode 侧)必须事先满足
17
+ * tip 要求,否则 BlockRazor 会 reject。
18
+ *
19
+ * ## TPS 限制
20
+ *
21
+ * - 默认 10 TPS(不分套餐);联系 BlockRazor 升级。
22
+ */
23
+
24
+ import { SendChannel, SendChannelResult } from './types'
25
+ import { log_info, log_warn } from '@clonegod/ttd-core'
26
+ import { Pool } from 'undici'
27
+
28
+ interface JsonRpcResponse {
29
+ jsonrpc: string
30
+ id: number | string
31
+ result?: string
32
+ error?: { code: number; message: string }
33
+ }
34
+
35
+ const PING_INTERVAL_MS = 30_000
36
+
37
+ export class BlockRazorChannel implements SendChannel {
38
+ readonly name = 'blockrazor'
39
+ private pool: Pool
40
+ private origin: string
41
+ private path: string
42
+ private authHeader: string
43
+ private pingTimer?: NodeJS.Timeout
44
+
45
+ constructor(private url: string, authToken: string) {
46
+ if (!authToken) {
47
+ throw new Error('[BlockRazorChannel] authToken is required')
48
+ }
49
+ const u = new URL(url)
50
+ this.origin = `${u.protocol}//${u.host}`
51
+ this.path = `${u.pathname}${u.search}` || '/'
52
+ this.authHeader = `Bearer ${authToken}`
53
+
54
+ this.pool = new Pool(this.origin, {
55
+ connections: 4,
56
+ pipelining: 1,
57
+ keepAliveTimeout: 10 * 60_000,
58
+ keepAliveMaxTimeout: 10 * 60_000,
59
+ headersTimeout: 5_000,
60
+ bodyTimeout: 5_000,
61
+ })
62
+ }
63
+
64
+ async warmup(timeoutMs: number = 5000): Promise<void> {
65
+ const t0 = Date.now()
66
+ try {
67
+ const json = await this.rpcCall('eth_chainId', [], timeoutMs)
68
+ if (json?.result) {
69
+ log_info(`[${this.name}] warmup OK ${this.url} chainId=${json.result} ${Date.now() - t0}ms`)
70
+ } else {
71
+ log_warn(`[${this.name}] warmup soft-fail ${this.url} ${Date.now() - t0}ms err=${json?.error?.message || 'no result'}`)
72
+ }
73
+ } catch (e: any) {
74
+ log_warn(`[${this.name}] warmup err ${this.url} ${Date.now() - t0}ms: ${e?.message || e}`)
75
+ }
76
+
77
+ if (!this.pingTimer) {
78
+ this.pingTimer = setInterval(() => {
79
+ this.rpcCall('eth_chainId', [], 3_000).catch(() => { /* silent */ })
80
+ }, PING_INTERVAL_MS)
81
+ this.pingTimer.unref?.()
82
+ }
83
+ }
84
+
85
+ async send(signedTx: string, timeoutMs: number): Promise<SendChannelResult> {
86
+ const t0 = Date.now()
87
+ try {
88
+ const json = await this.rpcCall('eth_sendRawTransaction', [signedTx], timeoutMs)
89
+ if (json?.error) {
90
+ return { channel: this.name, elapsedMs: Date.now() - t0, error: `RPC: ${json.error.message}` }
91
+ }
92
+ return { channel: this.name, elapsedMs: Date.now() - t0, txHash: json?.result }
93
+ } catch (e: any) {
94
+ const msg = e?.name === 'AbortError' || e?.message?.includes('aborted') ? `timeout(${timeoutMs}ms)` : (e?.message || String(e))
95
+ log_warn(`[${this.name}] send error: ${msg}`)
96
+ return { channel: this.name, elapsedMs: Date.now() - t0, error: msg }
97
+ }
98
+ }
99
+
100
+ private async rpcCall(method: string, params: any[], timeoutMs: number): Promise<JsonRpcResponse | null> {
101
+ const controller = new AbortController()
102
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
103
+ try {
104
+ const { statusCode, body } = await this.pool.request({
105
+ method: 'POST',
106
+ path: this.path,
107
+ headers: {
108
+ 'content-type': 'application/json',
109
+ 'authorization': this.authHeader,
110
+ },
111
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
112
+ signal: controller.signal,
113
+ })
114
+ if (statusCode < 200 || statusCode >= 300) {
115
+ try { await body.text() } catch { /* ignore */ }
116
+ throw new Error(`HTTP ${statusCode}`)
117
+ }
118
+ const text = await body.text()
119
+ return JSON.parse(text) as JsonRpcResponse
120
+ } finally {
121
+ clearTimeout(timer)
122
+ }
123
+ }
124
+
125
+ async close(): Promise<void> {
126
+ if (this.pingTimer) {
127
+ clearInterval(this.pingTimer)
128
+ this.pingTimer = undefined
129
+ }
130
+ await this.pool.close()
131
+ }
132
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * bloXroute BASE 通道
3
+ *
4
+ * - 文档:https://docs.bloxroute.com/base/submit-transactions/submit-transactions
5
+ * - Speed Boost:https://docs.bloxroute.com/base/submit-transactions/speed-boost
6
+ * - 协议:HTTP POST `blxr_tx`(**非** eth_sendRawTransaction,是 bloXroute 私有方法)
7
+ * - 认证:`Authorization: <token>`(注意**无** Bearer 前缀)
8
+ * - Endpoint:`https://api.blxrbdn.com`
9
+ *
10
+ * ## 参数差异(vs 标准 JSON-RPC)
11
+ *
12
+ * - `transaction`: raw transactions bytes,**without `0x` prefix**
13
+ * - `blockchain_network`: 必须 `Base-Mainnet`
14
+ * - `backrunme_reward_address`: 可选——开启时自动注册 BackRunMe,回扣 MEV 利润到此地址
15
+ *
16
+ * ## 与 BlockRazor 的不同
17
+ *
18
+ * - **无强制 tip**:不需要 caller 在 tx 内含 transfer 或抬高 priorityFee
19
+ * - Speed Boost 需另购(通过 portal);标准提交已经走私有路由
20
+ *
21
+ * ## 本通道做什么
22
+ *
23
+ * - 接收已签名 raw tx(带 0x 前缀),自动去前缀适配 bloXroute 协议
24
+ * - 注入 `blockchain_network = Base-Mainnet`
25
+ * - 可选注入 `backrunme_reward_address`
26
+ */
27
+
28
+ import { SendChannel, SendChannelResult } from './types'
29
+ import { log_info, log_warn } from '@clonegod/ttd-core'
30
+ import { Pool } from 'undici'
31
+
32
+ interface BlxrTxResponse {
33
+ jsonrpc?: string
34
+ id?: number | string
35
+ result?: { txHash?: string }
36
+ error?: { code?: number; message: string }
37
+ }
38
+
39
+ const PING_INTERVAL_MS = 30_000
40
+
41
+ export interface BloxrouteChannelOptions {
42
+ /** 完整 url,例如 https://api.blxrbdn.com */
43
+ url: string
44
+ /** Authorization header 完整值(bloXroute 不带 Bearer 前缀) */
45
+ authToken: string
46
+ /** 可选:BackRunMe 回扣收款地址(开启回扣服务时填) */
47
+ backrunmeRewardAddress?: string
48
+ }
49
+
50
+ export class BloxrouteChannel implements SendChannel {
51
+ readonly name = 'bloxroute'
52
+ private pool: Pool
53
+ private origin: string
54
+ private path: string
55
+ private authHeader: string
56
+ private backrunmeRewardAddress?: string
57
+ private pingTimer?: NodeJS.Timeout
58
+
59
+ constructor(opts: BloxrouteChannelOptions) {
60
+ if (!opts.authToken) {
61
+ throw new Error('[BloxrouteChannel] authToken is required')
62
+ }
63
+ const u = new URL(opts.url)
64
+ this.origin = `${u.protocol}//${u.host}`
65
+ this.path = `${u.pathname}${u.search}` || '/'
66
+ this.authHeader = opts.authToken
67
+ this.backrunmeRewardAddress = opts.backrunmeRewardAddress || undefined
68
+
69
+ this.pool = new Pool(this.origin, {
70
+ connections: 4,
71
+ pipelining: 1,
72
+ keepAliveTimeout: 10 * 60_000,
73
+ keepAliveMaxTimeout: 10 * 60_000,
74
+ headersTimeout: 5_000,
75
+ bodyTimeout: 5_000,
76
+ })
77
+ }
78
+
79
+ /**
80
+ * bloXroute 没有 `eth_chainId` 这种标准 ping。
81
+ * 这里只建 TCP+TLS(请求一个肯定返回的 404/method-not-found,仍能让连接进入复用池)。
82
+ */
83
+ async warmup(timeoutMs: number = 5000): Promise<void> {
84
+ const t0 = Date.now()
85
+ try {
86
+ await this.postRaw('{"jsonrpc":"2.0","id":1,"method":"ping","params":[]}', timeoutMs)
87
+ log_info(`[${this.name}] warmup OK ${this.origin} ${Date.now() - t0}ms`)
88
+ } catch (e: any) {
89
+ log_warn(`[${this.name}] warmup err ${this.origin} ${Date.now() - t0}ms: ${e?.message || e}`)
90
+ }
91
+
92
+ if (!this.pingTimer) {
93
+ this.pingTimer = setInterval(() => {
94
+ this.postRaw('{"jsonrpc":"2.0","id":1,"method":"ping","params":[]}', 3_000)
95
+ .catch(() => { /* silent */ })
96
+ }, PING_INTERVAL_MS)
97
+ this.pingTimer.unref?.()
98
+ }
99
+ }
100
+
101
+ async send(signedTx: string, timeoutMs: number): Promise<SendChannelResult> {
102
+ const t0 = Date.now()
103
+ // bloXroute 要求 raw tx **去掉 0x 前缀**
104
+ const rawTx = signedTx.startsWith('0x') ? signedTx.slice(2) : signedTx
105
+ const params: Record<string, any> = {
106
+ transaction: rawTx,
107
+ blockchain_network: 'Base-Mainnet',
108
+ }
109
+ if (this.backrunmeRewardAddress) {
110
+ params.backrunme_reward_address = this.backrunmeRewardAddress
111
+ }
112
+
113
+ try {
114
+ const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'blxr_tx', params })
115
+ const text = await this.postRaw(body, timeoutMs)
116
+ const json = JSON.parse(text) as BlxrTxResponse
117
+ if (json.error) {
118
+ return { channel: this.name, elapsedMs: Date.now() - t0, error: `RPC: ${json.error.message}` }
119
+ }
120
+ const hash = json.result?.txHash
121
+ const normalized = hash && !hash.startsWith('0x') ? `0x${hash}` : hash
122
+ return { channel: this.name, elapsedMs: Date.now() - t0, txHash: normalized }
123
+ } catch (e: any) {
124
+ const msg = e?.name === 'AbortError' || e?.message?.includes('aborted') ? `timeout(${timeoutMs}ms)` : (e?.message || String(e))
125
+ log_warn(`[${this.name}] send error: ${msg}`)
126
+ return { channel: this.name, elapsedMs: Date.now() - t0, error: msg }
127
+ }
128
+ }
129
+
130
+ private async postRaw(body: string, timeoutMs: number): Promise<string> {
131
+ const controller = new AbortController()
132
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
133
+ try {
134
+ const { statusCode, body: respBody } = await this.pool.request({
135
+ method: 'POST',
136
+ path: this.path,
137
+ headers: {
138
+ 'content-type': 'application/json',
139
+ 'authorization': this.authHeader,
140
+ },
141
+ body,
142
+ signal: controller.signal,
143
+ })
144
+ const text = await respBody.text()
145
+ if (statusCode < 200 || statusCode >= 300) {
146
+ throw new Error(`HTTP ${statusCode}: ${text.slice(0, 200)}`)
147
+ }
148
+ return text
149
+ } finally {
150
+ clearTimeout(timer)
151
+ }
152
+ }
153
+
154
+ async close(): Promise<void> {
155
+ if (this.pingTimer) {
156
+ clearInterval(this.pingTimer)
157
+ this.pingTimer = undefined
158
+ }
159
+ await this.pool.close()
160
+ }
161
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * 直发 sequencer RPC 通道(BASE 主路径)
3
+ *
4
+ * - HTTP POST eth_sendRawTransaction 到 BASE sequencer
5
+ * - 默认 https://mainnet.base.org(Coinbase 官方)
6
+ * - 生产建议换 Alchemy / QuickNode 的 private endpoint:跟 sequencer 有专线,延迟更低
7
+ *
8
+ * ## BASE 上为什么不需要"MEV 保护通道"
9
+ *
10
+ * BASE 是单 sequencer rollup(Coinbase 运维),具备 ETH 主网用 Flashbots Protect 想达到的特性:
11
+ * 1. **无公开 mempool**:sequencer 接收到的 tx 不广播给公网
12
+ * 2. **无 builder 竞价**:没有第三方 builder 看你的 tx,没有 PBS 拍卖
13
+ * 3. **Flashblocks 排序锁定**:tx 一旦进入 Flashblock 就不能被高 fee tx 越过
14
+ *
15
+ * Flashbots Protect 当前**不支持 BASE**(只支持 ETH 主网 + Sepolia),且事实上也不需要。
16
+ * 直发 sequencer 已经是 BASE 上的"最优策略"——速度即护身符。
17
+ *
18
+ * ## 延迟优化(vs Node 全局 fetch)
19
+ *
20
+ * 用持久化 undici Pool 替代全局 fetch,单独控制:
21
+ * 1. **keepAliveTimeout=10min**:服务端单方面断连前一直保活,arb 低谷期不掉
22
+ * 2. **keepAliveMaxTimeout=10min**:极限保活上限
23
+ * 3. **pipelining=1**:避免请求重排序导致的 nonce 抢跑
24
+ * 4. **connections=4**:单 host 4 个并发连接(足够 arb 突发)
25
+ * 5. **periodic ping**:每 30s 发 eth_chainId,强制走"已建立"连接防服务端 idle 断
26
+ *
27
+ * 累计收益:消除 ~100-300ms 冷启动 + arb 低谷期回弹的一致性问题。
28
+ *
29
+ * ## 注意事项
30
+ *
31
+ * - **无 revert protection**:失败的 tx 会上链扣 gas → 业务必须先做链下模拟
32
+ */
33
+
34
+ import { SendChannel, SendChannelResult } from './types'
35
+ import { log_info, log_warn } from '@clonegod/ttd-core'
36
+ import { Pool } from 'undici'
37
+
38
+ interface JsonRpcResponse {
39
+ jsonrpc: string
40
+ id: number | string
41
+ result?: string
42
+ error?: { code: number; message: string }
43
+ }
44
+
45
+ const PING_INTERVAL_MS = 30_000
46
+
47
+ export class DirectRpcChannel implements SendChannel {
48
+ readonly name = 'direct_rpc'
49
+ private pool: Pool
50
+ private origin: string
51
+ private path: string
52
+ private pingTimer?: NodeJS.Timeout
53
+
54
+ constructor(private url: string) {
55
+ const u = new URL(url)
56
+ this.origin = `${u.protocol}//${u.host}`
57
+ this.path = `${u.pathname}${u.search}` || '/'
58
+
59
+ // 持久化 undici Pool:keep-alive 极长,确保 idle 期间不断
60
+ this.pool = new Pool(this.origin, {
61
+ connections: 4,
62
+ pipelining: 1,
63
+ keepAliveTimeout: 10 * 60_000, // 10 分钟
64
+ keepAliveMaxTimeout: 10 * 60_000,
65
+ headersTimeout: 5_000,
66
+ bodyTimeout: 5_000,
67
+ })
68
+ }
69
+
70
+ /**
71
+ * 启动期预热 + 启动后台 ping。
72
+ * 预热建立 TCP+TLS,让第一笔真实交易免握手;
73
+ * 后台 ping 每 30s 走一次连接防服务端 idle 断。
74
+ */
75
+ async warmup(timeoutMs: number = 5000): Promise<void> {
76
+ const t0 = Date.now()
77
+ try {
78
+ const json = await this.rpcCall('eth_chainId', [], timeoutMs)
79
+ if (json?.result) {
80
+ log_info(`[${this.name}] warmup OK ${this.url} chainId=${json.result} ${Date.now() - t0}ms`)
81
+ } else {
82
+ log_warn(`[${this.name}] warmup soft-fail ${this.url} ${Date.now() - t0}ms err=${json?.error?.message || 'no result'}`)
83
+ }
84
+ } catch (e: any) {
85
+ log_warn(`[${this.name}] warmup err ${this.url} ${Date.now() - t0}ms: ${e?.message || e}`)
86
+ }
87
+
88
+ // 启动后台 ping(fire-and-forget,进程退出时随之回收)
89
+ if (!this.pingTimer) {
90
+ this.pingTimer = setInterval(() => {
91
+ this.rpcCall('eth_chainId', [], 3_000).catch(() => { /* 静默 — 失败由下一次 send 触发重连 */ })
92
+ }, PING_INTERVAL_MS)
93
+ this.pingTimer.unref?.()
94
+ }
95
+ }
96
+
97
+ async send(signedTx: string, timeoutMs: number): Promise<SendChannelResult> {
98
+ const t0 = Date.now()
99
+ try {
100
+ const json = await this.rpcCall('eth_sendRawTransaction', [signedTx], timeoutMs)
101
+ if (json?.error) {
102
+ return { channel: this.name, elapsedMs: Date.now() - t0, error: `RPC: ${json.error.message}` }
103
+ }
104
+ return { channel: this.name, elapsedMs: Date.now() - t0, txHash: json?.result }
105
+ } catch (e: any) {
106
+ const msg = e?.name === 'AbortError' || e?.message?.includes('aborted') ? `timeout(${timeoutMs}ms)` : (e?.message || String(e))
107
+ log_warn(`[${this.name}] send error: ${msg}`)
108
+ return { channel: this.name, elapsedMs: Date.now() - t0, error: msg }
109
+ }
110
+ }
111
+
112
+ private async rpcCall(method: string, params: any[], timeoutMs: number): Promise<JsonRpcResponse | null> {
113
+ const controller = new AbortController()
114
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
115
+ try {
116
+ const { statusCode, body } = await this.pool.request({
117
+ method: 'POST',
118
+ path: this.path,
119
+ headers: { 'content-type': 'application/json' },
120
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
121
+ signal: controller.signal,
122
+ })
123
+ if (statusCode < 200 || statusCode >= 300) {
124
+ // 消费 body 防内存泄露
125
+ try { await body.text() } catch { /* ignore */ }
126
+ throw new Error(`HTTP ${statusCode}`)
127
+ }
128
+ const text = await body.text()
129
+ return JSON.parse(text) as JsonRpcResponse
130
+ } finally {
131
+ clearTimeout(timer)
132
+ }
133
+ }
134
+
135
+ /** 测试/优雅停机用:销毁连接池 + 停 ping */
136
+ async close(): Promise<void> {
137
+ if (this.pingTimer) {
138
+ clearInterval(this.pingTimer)
139
+ this.pingTimer = undefined
140
+ }
141
+ await this.pool.close()
142
+ }
143
+ }
@@ -0,0 +1,4 @@
1
+ export * from './types'
2
+ export * from './direct_rpc'
3
+ export * from './blockrazor'
4
+ export * from './bloxroute'
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 发送通道抽象 — 所有 BASE 发送通道(Flashbots Protect / 直发 sequencer / 未来的私有 RPC)
3
+ * 都通过一个统一接口暴露给 TransactionSender。
4
+ *
5
+ * 设计目标:
6
+ * - 通道是纯函数:拿到 signedTx + timeout,返回结果或抛错;不关心策略/优先级
7
+ * - TransactionSender 负责并发 / 顺序 / 重试编排
8
+ * - 通道之间相互独立,可热插拔
9
+ */
10
+
11
+ export interface SendChannelResult {
12
+ /** 通道名(用于日志/分析)*/
13
+ channel: string
14
+ /** sequencer / relay 返回的 tx hash(成功);undefined = 失败但无 hash */
15
+ txHash?: string
16
+ /** 总耗时(ms)*/
17
+ elapsedMs: number
18
+ /** 错误信息(失败时)*/
19
+ error?: string
20
+ }
21
+
22
+ export interface SendChannel {
23
+ readonly name: string
24
+ /**
25
+ * @param signedTx hex-encoded raw transaction(必须以 0x 开头)
26
+ * @param timeoutMs 单次发送超时(ms);超时即视为失败
27
+ */
28
+ send(signedTx: string, timeoutMs: number): Promise<SendChannelResult>
29
+ /**
30
+ * 可选:启动期连接预热(建立 TCP+TLS,复用给后续 send)。
31
+ * 实现方应 fire-and-forget:失败不抛错,仅 log。
32
+ */
33
+ warmup?(timeoutMs?: number): Promise<void>
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { TransactionSender } from './transaction_sender'
2
+ export { BASE_EOA_ADDRESS } from '@clonegod/ttd-base-common'