Skip to content

Commit 20df3a3

Browse files
devin-ai-integration[bot]ali-behjatitejasbadadare
authored
feat: support multiple JITO endpoints with round-robin retry (#2664)
* feat: support multiple JITO endpoints with round-robin retry Co-Authored-By: Ali Behjati <ali@dourolabs.xyz> * fix: rename jito-endpoint to jito-endpoints Co-Authored-By: Ali Behjati <ali@dourolabs.xyz> * fix: update readme, logs, version * fix: simplify retry logic, avoid retrying on top of next push attempt * chore: bump ver --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Ali Behjati <ali@dourolabs.xyz> Co-authored-by: Tejas Badadare <tejasbadadare@gmail.com>
1 parent 44b0b50 commit 20df3a3

File tree

7 files changed

+111
-44
lines changed

7 files changed

+111
-44
lines changed

apps/price_pusher/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ pnpm run start solana \
159159
--endpoint https://api.mainnet-beta.solana.com \
160160
--keypair-file ./id.json \
161161
--shard-id 1 \
162-
--jito-endpoint mainnet.block-engine.jito.wtf \
162+
--jito-endpoints mainnet.block-engine.jito.wtf,ny.mainnet.block-engine.jito.wtf \
163163
--jito-keypair-file ./jito.json \
164164
--jito-tip-lamports 100000 \
165165
--jito-bundle-size 5 \

apps/price_pusher/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pythnetwork/price-pusher",
3-
"version": "9.3.4",
3+
"version": "9.3.5",
44
"description": "Pyth Price Pusher",
55
"homepage": "https://pyth.network",
66
"main": "lib/index.js",

apps/price_pusher/src/solana/command.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ export default {
4949
type: "number",
5050
default: 50000,
5151
} as Options,
52-
"jito-endpoint": {
53-
description: "Jito endpoint",
52+
"jito-endpoints": {
53+
description: "Jito endpoint(s) - comma-separated list of endpoints",
5454
type: "string",
5555
optional: true,
5656
} as Options,
@@ -117,7 +117,7 @@ export default {
117117
pythContractAddress,
118118
pushingFrequency,
119119
pollingFrequency,
120-
jitoEndpoint,
120+
jitoEndpoints,
121121
jitoKeypairFile,
122122
jitoTipLamports,
123123
dynamicJitoTips,
@@ -209,7 +209,18 @@ export default {
209209
Uint8Array.from(JSON.parse(fs.readFileSync(jitoKeypairFile, "ascii"))),
210210
);
211211

212-
const jitoClient = searcherClient(jitoEndpoint, jitoKeypair);
212+
const jitoEndpointsList = jitoEndpoints
213+
.split(",")
214+
.map((endpoint: string) => endpoint.trim());
215+
const jitoClients: SearcherClient[] = jitoEndpointsList.map(
216+
(endpoint: string) => {
217+
logger.info(
218+
`Constructing Jito searcher client from endpoint ${endpoint}`,
219+
);
220+
return searcherClient(endpoint, jitoKeypair);
221+
},
222+
);
223+
213224
solanaPricePusher = new SolanaPricePusherJito(
214225
pythSolanaReceiver,
215226
hermesClient,
@@ -218,13 +229,17 @@ export default {
218229
jitoTipLamports,
219230
dynamicJitoTips,
220231
maxJitoTipLamports,
221-
jitoClient,
232+
jitoClients,
222233
jitoBundleSize,
223234
updatesPerJitoBundle,
235+
// Set max retry time to pushing frequency, since we want to stop retrying before the next push attempt
236+
pushingFrequency * 1000,
224237
lookupTableAccount,
225238
);
226239

227-
onBundleResult(jitoClient, logger.child({ module: "JitoClient" }));
240+
jitoClients.forEach((client, index) => {
241+
onBundleResult(client, logger.child({ module: `JitoClient-${index}` }));
242+
});
228243
} else {
229244
solanaPricePusher = new SolanaPricePusher(
230245
pythSolanaReceiver,

apps/price_pusher/src/solana/solana.ts

Lines changed: 11 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,10 @@ export class SolanaPricePusherJito implements IPricePusher {
166166
private defaultJitoTipLamports: number,
167167
private dynamicJitoTips: boolean,
168168
private maxJitoTipLamports: number,
169-
private searcherClient: SearcherClient,
169+
private searcherClients: SearcherClient[],
170170
private jitoBundleSize: number,
171171
private updatesPerJitoBundle: number,
172+
private maxRetryTimeMs: number,
172173
private addressLookupTableAccount?: AddressLookupTableAccount,
173174
) {}
174175

@@ -194,10 +195,6 @@ export class SolanaPricePusherJito implements IPricePusher {
194195
}
195196
}
196197

197-
private async sleep(ms: number): Promise<void> {
198-
return new Promise((resolve) => setTimeout(resolve, ms));
199-
}
200-
201198
async updatePriceFeed(priceIds: string[]): Promise<void> {
202199
const recentJitoTip = await this.getRecentJitoTipLamports();
203200
const jitoTip =
@@ -243,32 +240,15 @@ export class SolanaPricePusherJito implements IPricePusher {
243240
jitoBundleSize: this.jitoBundleSize,
244241
});
245242

246-
let retries = 60;
247-
while (retries > 0) {
248-
try {
249-
await sendTransactionsJito(
250-
transactions,
251-
this.searcherClient,
252-
this.pythSolanaReceiver.wallet,
253-
);
254-
break;
255-
} catch (err: any) {
256-
if (err.code === 8 && err.details?.includes("Rate limit exceeded")) {
257-
this.logger.warn("Rate limit hit, waiting before retry...");
258-
await this.sleep(1100); // Wait slightly more than 1 second
259-
retries--;
260-
if (retries === 0) {
261-
this.logger.error("Max retries reached for rate limit");
262-
throw err;
263-
}
264-
} else {
265-
throw err;
266-
}
267-
}
268-
}
269-
270-
// Add a delay between bundles to avoid rate limiting
271-
await this.sleep(1100);
243+
await sendTransactionsJito(
244+
transactions,
245+
this.searcherClients,
246+
this.pythSolanaReceiver.wallet,
247+
{
248+
maxRetryTimeMs: this.maxRetryTimeMs,
249+
},
250+
this.logger,
251+
);
272252
}
273253
}
274254
}

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

target_chains/solana/sdk/js/solana_utils/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pythnetwork/solana-utils",
3-
"version": "0.4.4",
3+
"version": "0.4.5",
44
"description": "Utility functions for Solana",
55
"homepage": "https://pyth.network",
66
"main": "lib/index.js",
@@ -49,6 +49,7 @@
4949
"@coral-xyz/anchor": "^0.29.0",
5050
"@solana/web3.js": "^1.90.0",
5151
"bs58": "^5.0.0",
52-
"jito-ts": "^3.0.1"
52+
"jito-ts": "^3.0.1",
53+
"ts-log": "^2.2.7"
5354
}
5455
}

target_chains/solana/sdk/js/solana_utils/src/jito.ts

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { dummyLogger, Logger } from "ts-log";
12
import { Wallet } from "@coral-xyz/anchor";
23
import {
34
PublicKey,
@@ -42,9 +43,27 @@ export async function sendTransactionsJito(
4243
tx: VersionedTransaction;
4344
signers?: Signer[] | undefined;
4445
}[],
45-
searcherClient: SearcherClient,
46+
searcherClients: SearcherClient | SearcherClient[],
4647
wallet: Wallet,
48+
options: {
49+
maxRetryTimeMs?: number; // Max time to retry sending transactions
50+
delayBetweenCyclesMs?: number; // Delay between cycles of sending transactions to all searcher clients
51+
} = {},
52+
logger: Logger = dummyLogger, // Optional logger to track progress of retries
4753
): Promise<string> {
54+
const clients = Array.isArray(searcherClients)
55+
? searcherClients
56+
: [searcherClients];
57+
58+
if (clients.length === 0) {
59+
throw new Error("No searcher clients provided");
60+
}
61+
62+
const maxRetryTimeMs = options.maxRetryTimeMs || 60000; // Default to 60 seconds
63+
const delayBetweenCyclesMs = options.delayBetweenCyclesMs || 1000; // Default to 1 second
64+
65+
const startTime = Date.now();
66+
4867
const signedTransactions = [];
4968

5069
for (const transaction of transactions) {
@@ -64,7 +83,56 @@ export async function sendTransactionsJito(
6483
);
6584

6685
const bundle = new Bundle(signedTransactions, 2);
67-
await searcherClient.sendBundle(bundle);
6886

69-
return firstTransactionSignature;
87+
let lastError: Error | null = null;
88+
let totalAttempts = 0;
89+
90+
while (Date.now() - startTime < maxRetryTimeMs) {
91+
// Try all clients in this cycle
92+
for (let i = 0; i < clients.length; i++) {
93+
const currentClient = clients[i];
94+
totalAttempts++;
95+
96+
try {
97+
await currentClient.sendBundle(bundle);
98+
logger.info(
99+
{ clientIndex: i, totalAttempts },
100+
`Successfully sent bundle to Jito client after ${totalAttempts} attempts`,
101+
);
102+
return firstTransactionSignature;
103+
} catch (err: any) {
104+
lastError = err;
105+
logger.error(
106+
{ clientIndex: i, totalAttempts, err: err.message },
107+
`Attempt ${totalAttempts}: Error sending bundle to Jito client ${i}`,
108+
);
109+
}
110+
111+
// Check if we've run out of time
112+
if (Date.now() - startTime >= maxRetryTimeMs) {
113+
break;
114+
}
115+
}
116+
117+
// If we've tried all clients and still have time, wait before next cycle
118+
const timeRemaining = maxRetryTimeMs - (Date.now() - startTime);
119+
if (timeRemaining > delayBetweenCyclesMs) {
120+
await new Promise((resolve) => setTimeout(resolve, delayBetweenCyclesMs));
121+
}
122+
}
123+
124+
const totalTimeMs = Date.now() - startTime;
125+
const errorMsg = `Failed to send transactions via JITO after ${totalAttempts} attempts over ${totalTimeMs}ms (max: ${maxRetryTimeMs}ms)`;
126+
127+
logger.error(
128+
{
129+
totalAttempts,
130+
totalTimeMs,
131+
maxRetryTimeMs,
132+
lastError: lastError?.message,
133+
},
134+
errorMsg,
135+
);
136+
137+
throw lastError || new Error(errorMsg);
70138
}

0 commit comments

Comments
 (0)