├── .env.sample ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── ping-thing-client-jupiter.ts ├── ping-thing-client-token.ts ├── ping-thing-client.ts ├── tsconfig.json └── utils ├── blockhash.ts ├── grpfCustomRpcApi.ts ├── misc.ts ├── prometheus.ts └── slot.ts /.env.sample: -------------------------------------------------------------------------------- 1 | RPC_ENDPOINT=[YOUR_RPC_URL] 2 | WS_ENDPOINT=[YOUR_WS_URL] 3 | RPC_ENDPOINT_CASCADE=[YOUR_RPC_URL] 4 | RPC_ENDPOINT_ATLAS=[YOUR_RPC_URL] 5 | RPC_ENDPOINT_LITE=[YOUR_RPC_URL] 6 | RPC_ENDPOINT_PYTH=[YOUR_PYTHNET_RPC_URL] 7 | VA_API_KEY=[VALIDATORS_APP_API_KEY] 8 | WALLET_PRIVATE_KEYPAIR=[BASE58_VERSION_OF_YOUR_PRIVATE_KEY] 9 | SLEEP_MS_RPC=2000 10 | SLEEP_MS_LOOP=8000 11 | VERBOSE_LOG=false 12 | COMMITMENT=confirmed 13 | CU_BUDGET=5000 14 | PRIORITY_FEE_MICRO_LAMPORTS=3 15 | USE_PRIORITY_FEE=true 16 | # For the ping-thing-token.mjs script 17 | ATA_SEND='Your sending ATA address' 18 | ATA_REC='Your receiving ATA address' 19 | ATA_AMT=1 20 | MAX_SLOT_FETCH_ATTEMPTS=20 21 | MAX_BLOCKHASH_FETCH_ATTEMPTS=20 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | *lock.yaml 3 | node_modules/ 4 | temp.mjs 5 | *.log -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Block Logic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ping-thing-client 2 | Node JS Client to contribute ping times to the Validators.app Ping Thing. 3 | 4 | ## Install notes 5 | `git clone https://github.com/Block-Logic/ping-thing-client.git` 6 | `cd ping-thing-client/` 7 | 8 | Try `yarn install`. If that doesn't work, use: 9 | `yarn add @solana/web3.js` 10 | `yarn add dotenv` 11 | `yarn add xhr2` 12 | 13 | I use .env to hold sensitive data that I don't want to appear in the GitHub repo. Copy .env.sample to .env and replace the values inside the file with your data. The .env file needs your private wallet keypair in base58 format. There is a simple Ruby script that will convert a keypair.json file into base58. See keypair_to_base58.rb 14 | 15 | Before you can post pings to validators.app, you will need an API key. You can sign up at https://www.validators.app/users/sign_up and grab a free API key from your dashboard. Note: Your login handle will be displayed on the public Ping Thing page. 16 | 17 | After retrieving your API key, copy & paste it into the VA_API_KEY attribute of your .env file. 18 | 19 | In the .env file, try `VERBOSE_LOG=true` to see log output the first time you run the script. After saving your .env file, try running the script with `node ping-thing-client.mjs` and watch the output. I use `VERBOSE_LOG=false` in production to minimize log noise. 20 | 21 | ## Running the Ping Thing Script 22 | You can start the script & push it to the background with `node ping-thing-client.mjs >> ping-thing.log 2>&1 &`. 23 | 24 | 25 | ## RPC Notes 26 | If you are running the Ping Thing client on your validator node, you need to enable private RPC for access from your localhost. You also need to enable `--enable-rpc-transaction-history` to get the TX slot after confirmation. 27 | 28 | ### Misc Notes 29 | https://www.digitalocean.com/community/tutorials/how-to-install-node-js-on-ubuntu-20-04 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ping-thing-client", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "@solana-program/compute-budget": "^0.6.0", 14 | "@solana-program/system": "^0.6.2", 15 | "@solana-program/token": "^0.4.0", 16 | "@solana/promises": "^2.0.0", 17 | "@solana/transaction-confirmation": "^2.0.0", 18 | "@solana/kit": "2.1.0", 19 | "axios": "^1.7.3", 20 | "bs58": "^6.0.0", 21 | "dotenv": "^16.4.5", 22 | "express": "^4.21.2", 23 | "js-yaml": "^4.1.0", 24 | "prom-client": "^15.1.3", 25 | "undici": "^6.19.5" 26 | }, 27 | "devDependencies": { 28 | "@types/bun": "latest", 29 | "@types/express": "^5.0.0", 30 | "@types/node": "^20.12.7" 31 | }, 32 | "peerDependencies": { 33 | "typescript": "^5.0.0" 34 | } 35 | } -------------------------------------------------------------------------------- /ping-thing-client-jupiter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | setTransactionMessageLifetimeUsingBlockhash, 3 | createKeyPairFromBytes, 4 | getAddressFromPublicKey, 5 | signTransaction, 6 | sendTransactionWithoutConfirmingFactory, 7 | createSolanaRpcSubscriptions_UNSTABLE, 8 | getSignatureFromTransaction, 9 | compileTransaction, 10 | createSolanaRpc, 11 | SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND, 12 | isSolanaError, 13 | type Signature, 14 | type Commitment, 15 | getTransactionDecoder, 16 | getCompiledTransactionMessageDecoder, 17 | decompileTransactionMessageFetchingLookupTables, 18 | sendAndConfirmTransactionFactory, 19 | SOLANA_ERROR__BLOCK_HEIGHT_EXCEEDED, 20 | } from "@solana/kit"; 21 | import dotenv from "dotenv"; 22 | import bs58 from "bs58"; 23 | import { createRecentSignatureConfirmationPromiseFactory } from "@solana/transaction-confirmation"; 24 | import { sleep } from "./utils/misc.js"; 25 | import { watchBlockhash } from "./utils/blockhash.js"; 26 | import { watchSlotSent } from "./utils/slot.js"; 27 | import { setMaxListeners } from "events"; 28 | import axios from "axios"; 29 | import { safeRace } from "@solana/promises"; 30 | 31 | dotenv.config(); 32 | 33 | // Catch interrupts & exit 34 | process.on("SIGINT", function () { 35 | console.log(`Caught interrupt signal`, "\n"); 36 | process.exit(); 37 | }); 38 | 39 | const RPC_ENDPOINT = process.env.RPC_ENDPOINT; 40 | const WS_ENDPOINT = process.env.WS_ENDPOINT; 41 | 42 | const SLEEP_MS_RPC = process.env.SLEEP_MS_RPC ? parseInt(process.env.SLEEP_MS_RPC) : 2000; 43 | const SLEEP_MS_LOOP = process.env.SLEEP_MS_LOOP ? parseInt(process.env.SLEEP_MS_LOOP) : 0; 44 | const VA_API_KEY = process.env.VA_API_KEY; 45 | const VERBOSE_LOG = process.env.VERBOSE_LOG === "true" ? true : false; 46 | const COMMITMENT_LEVEL = process.env.COMMITMENT || "confirmed"; 47 | 48 | const SKIP_VALIDATORS_APP = process.env.SKIP_VALIDATORS_APP || false; 49 | 50 | const SWAP_TOKEN_FROM = "So11111111111111111111111111111111111111112"; // SOL 51 | const SWAP_TOKEN_TO = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; // USDC 52 | const SWAP_AMOUNT = 1000; 53 | 54 | const JUPITER_ENDPOINT = `${RPC_ENDPOINT}/jupiter`; 55 | const PRIORITY_FEE_PERCENTILE = process.env.PRIORITY_FEE_PERCENTILE || 5000; 56 | 57 | if (VERBOSE_LOG) console.log(`Starting script`); 58 | 59 | // RPC connection for HTTP API calls, equivalent to `const c = new Connection(RPC_ENDPOINT)` 60 | const rpcConnection = createSolanaRpc(RPC_ENDPOINT!); 61 | 62 | // RPC connection for websocket connection 63 | const rpcSubscriptions = createSolanaRpcSubscriptions_UNSTABLE(WS_ENDPOINT!); 64 | 65 | let USER_KEYPAIR; 66 | const TX_RETRY_INTERVAL = 2000; 67 | 68 | // Global blockhash value fetching constantly in a loop 69 | const gBlockhash = { value: null, updated_at: 0, lastValidBlockHeight: BigInt(0) }; 70 | 71 | // Record new slot on `firstShredReceived` fetched from a slot subscription 72 | const gSlotSent = { value: null, updated_at: 0 }; 73 | 74 | // main ping thing function 75 | async function pingThing() { 76 | USER_KEYPAIR = await createKeyPairFromBytes( 77 | bs58.decode(process.env.WALLET_PRIVATE_KEYPAIR!) 78 | ); 79 | 80 | // Pre-define loop constants & variables 81 | const FAKE_SIGNATURE = 82 | "9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999"; 83 | 84 | // Run inside a loop that will exit after 3 consecutive failures 85 | const MAX_TRIES = 3; 86 | let tryCount = 0; 87 | 88 | // Infinite loop to keep this running forever 89 | while (true) { 90 | await sleep(SLEEP_MS_LOOP); 91 | 92 | let blockhash; 93 | let lastValidBlockHeight; 94 | let slotSent; 95 | let slotLanded; 96 | let signature; 97 | let txStart; 98 | let txSendAttempts = 1; 99 | 100 | let tempResponse; 101 | 102 | let quoteResponse; 103 | let jupiterSwapTransaction; 104 | 105 | // Wait for fresh slot and blockhash 106 | while (true) { 107 | if ( 108 | Date.now() - gBlockhash.updated_at < 10000 && 109 | Date.now() - gSlotSent.updated_at < 50 110 | ) { 111 | blockhash = gBlockhash.value; 112 | lastValidBlockHeight = gBlockhash.lastValidBlockHeight; 113 | slotSent = gSlotSent.value; 114 | break; 115 | } 116 | 117 | await sleep(1); 118 | } 119 | 120 | try { 121 | try { 122 | if (VERBOSE_LOG) 123 | console.log( 124 | `fetching jupiter swap quote` 125 | ); 126 | 127 | // Get quote for swap 128 | tempResponse = await axios.get( 129 | `${JUPITER_ENDPOINT}/quote?inputMint=${SWAP_TOKEN_FROM}&outputMint=${SWAP_TOKEN_TO}&amount=${SWAP_AMOUNT}&slippageBps=50` 130 | ); 131 | 132 | // Throw error if response is not ok 133 | if (!(tempResponse.status >= 200) && tempResponse.status < 300) { 134 | throw new Error( 135 | `Failed to fetch jupiter swap quote: ${tempResponse.status}` 136 | ); 137 | } 138 | 139 | quoteResponse = tempResponse.data; 140 | 141 | if (VERBOSE_LOG) 142 | console.log(`fetched jupiter swap quote`); 143 | 144 | // get priority fees from teh improved priority fees api 145 | // https://docs.triton.one/chains/solana/improved-priority-fees-api 146 | const priorityFeeApiResult = await axios.post(`${RPC_ENDPOINT}`, { 147 | method: "getRecentPrioritizationFees", 148 | jsonrpc: "2.0", 149 | params: [ 150 | ["JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"], 151 | { 152 | percentile: parseInt(PRIORITY_FEE_PERCENTILE.toString()), 153 | }, 154 | ], 155 | id: "1", 156 | }); 157 | 158 | // get the fees array from the response array 159 | const fees: number[] = priorityFeeApiResult.data.result.map((i: { slot: number, prioritizationFee: number }) => i.prioritizationFee); 160 | 161 | const medianFees = fees.sort()[Math.floor(fees.length / 2)] 162 | 163 | if (VERBOSE_LOG) 164 | console.log(`fetched global priority fees for jupiter`); 165 | 166 | const userPublicKey = await getAddressFromPublicKey(USER_KEYPAIR.publicKey); 167 | 168 | // Get swap transaction 169 | tempResponse = await axios.post(`${JUPITER_ENDPOINT}/swap`, { 170 | quoteResponse: quoteResponse, 171 | userPublicKey: userPublicKey.toString(), 172 | wrapAndUnwrapSol: true, 173 | dynamicComputeUnitLimit: true, 174 | prioritizationFeeLamports: medianFees, 175 | }); 176 | // throw error if response is not ok 177 | if (!(tempResponse.status >= 200) && tempResponse.status < 300) { 178 | throw new Error( 179 | `failed to fetch jupiter swap transaction: ${tempResponse.status}` 180 | ); 181 | } 182 | 183 | if (VERBOSE_LOG) 184 | console.log( 185 | `fetched jupiter swap transaction` 186 | ); 187 | 188 | jupiterSwapTransaction = tempResponse.data; 189 | 190 | const swapTransactionBuffer = Buffer.from( 191 | jupiterSwapTransaction.swapTransaction, 192 | "base64" 193 | ); 194 | 195 | // ---- Start: Decode and parse the transaction ---- 196 | const transactionDecoder = getTransactionDecoder() 197 | const decodedTx = transactionDecoder.decode(swapTransactionBuffer) 198 | 199 | const compiledTransactionMessageDecoder = getCompiledTransactionMessageDecoder() 200 | const compiledTransactionMessage = compiledTransactionMessageDecoder.decode(decodedTx.messageBytes) 201 | 202 | const txMessage = await decompileTransactionMessageFetchingLookupTables(compiledTransactionMessage, rpcConnection) 203 | 204 | // Set blockhash 205 | const finalTxWithBlockhash = setTransactionMessageLifetimeUsingBlockhash({ blockhash: blockhash!, lastValidBlockHeight: lastValidBlockHeight }, txMessage) 206 | 207 | // Sign the tx 208 | const transactionSignedWithFeePayer = await signTransaction( 209 | [USER_KEYPAIR], 210 | compileTransaction(finalTxWithBlockhash) 211 | ); 212 | 213 | signature = getSignatureFromTransaction(transactionSignedWithFeePayer); 214 | 215 | // ---- End: Decode and parse the transaction ---- 216 | 217 | // Note the timestamp we begin sending the tx, we'll compare it with the 218 | // timestamp when the tx is confirmed to mesaure the tx latency 219 | txStart = Date.now(); 220 | 221 | console.log(`Sending ${signature}`); 222 | 223 | // The tx sendinng and confirming startegy of the Ping Thing is as follow: 224 | // 1. Send the tranaction 225 | // 2. Subscribe to the tx signature and listen for dersied commitment change 226 | // 3. Send the tx again if not confrmed within 2000ms 227 | // 4. Stop sending when tx is confirmed 228 | 229 | // Create a sender factory that sends a transaction and doesn't wait for confirmation 230 | const mSendTransaction = sendTransactionWithoutConfirmingFactory({ 231 | rpc: rpcConnection, 232 | }); 233 | 234 | // Create a promise factory that has the logic for a the tx to be confirmed 235 | const getRecentSignatureConfirmationPromise = 236 | createRecentSignatureConfirmationPromiseFactory({ 237 | rpc: rpcConnection, 238 | rpcSubscriptions, 239 | }); 240 | 241 | setMaxListeners(100); 242 | 243 | // Incase we want to abort the promise that's waiting for a tx to be confirmed 244 | const abortController = new AbortController(); 245 | 246 | while (true) { 247 | const sendAndConfirmTransaction = sendAndConfirmTransactionFactory({ rpc: rpcConnection, rpcSubscriptions }); 248 | 249 | try { 250 | await safeRace([ 251 | sendAndConfirmTransaction(transactionSignedWithFeePayer, { maxRetries: 0n, skipPreflight: true, commitment: "confirmed", abortSignal: abortController.signal }), 252 | sleep(TX_RETRY_INTERVAL).then(() => { 253 | throw new Error("TxSendTimeout"); 254 | }), 255 | ]); 256 | 257 | console.log(`Confirmed tx ${signature}`); 258 | 259 | break; 260 | } catch (e: any) { 261 | if (e.message === "TxSendTimeout") { 262 | console.log(`Tx not confirmed after ${TX_RETRY_INTERVAL * txSendAttempts++}ms, resending`) 263 | continue; 264 | } else if (isSolanaError(e, SOLANA_ERROR__BLOCK_HEIGHT_EXCEEDED)) { 265 | throw new Error("TransactionExpiredBlockheightExceededError") 266 | } else { 267 | throw e; 268 | } 269 | 270 | } 271 | 272 | } 273 | } catch (e: any) { 274 | // Log and loop if we get a bad blockhash. 275 | if ( 276 | isSolanaError(e, SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND) 277 | ) { 278 | // Same as `if (e.message.includes("Blockhash not found")) {` 279 | console.log(`ERROR: Blockhash not found`); 280 | continue; 281 | } 282 | 283 | // If the transaction expired on the chain. Make a log entry and send 284 | // to VA. Otherwise log and loop. 285 | if (e.name === "TransactionExpiredBlockheightExceededError") { 286 | console.log( 287 | `ERROR: Blockhash expired/block height exceeded. TX failure sent to VA.` 288 | ); 289 | } else { 290 | console.log(`ERROR: ${e.name}`); 291 | console.log(e.message); 292 | console.log(e); 293 | console.log(JSON.stringify(e)); 294 | continue; 295 | } 296 | 297 | // Need to submit a fake signature to pass the import filters 298 | signature = FAKE_SIGNATURE as Signature; 299 | } 300 | 301 | const txEnd = Date.now(); 302 | // Sleep a little here to ensure the signature is on an RPC node. 303 | await sleep(SLEEP_MS_RPC); 304 | if (signature !== FAKE_SIGNATURE) { 305 | // Capture the slotLanded 306 | let txLanded = await rpcConnection 307 | .getTransaction(signature, { 308 | commitment: COMMITMENT_LEVEL as Commitment, 309 | maxSupportedTransactionVersion: 0, 310 | }) 311 | .send(); 312 | if (txLanded === null) { 313 | console.log( 314 | signature, 315 | `ERROR: tx is not found on RPC within ${SLEEP_MS_RPC}ms. Not sending to VA.` 316 | ); 317 | continue; 318 | } 319 | slotLanded = txLanded.slot; 320 | } 321 | 322 | // Don't send if the slot latency is negative 323 | if (slotLanded! < slotSent!) { 324 | console.log( 325 | signature, 326 | `ERROR: Slot ${slotLanded} < ${slotSent}. Not sending to VA.` 327 | ); 328 | continue; 329 | } 330 | 331 | // Prepare the payload to send to validators.app 332 | const vAPayload = JSON.stringify({ 333 | time: txEnd - txStart!, 334 | signature, 335 | transaction_type: "transfer", 336 | success: signature !== FAKE_SIGNATURE, 337 | application: "web3", 338 | commitment_level: COMMITMENT_LEVEL, 339 | slot_sent: BigInt(slotSent!).toString(), 340 | slot_landed: BigInt(slotLanded!).toString(), 341 | }); 342 | if (VERBOSE_LOG) { 343 | console.log(vAPayload); 344 | } 345 | 346 | if (!SKIP_VALIDATORS_APP) { 347 | // Send the payload to validators.app 348 | const vaResponse = await axios.post( 349 | "https://www.validators.app/api/v1/ping-thing/mainnet", 350 | vAPayload, 351 | { 352 | headers: { 353 | "Content-Type": "application/json", 354 | Token: VA_API_KEY, 355 | }, 356 | } 357 | ); 358 | // throw error if response is not ok 359 | if (!(vaResponse.status >= 200 && vaResponse.status <= 299)) { 360 | throw new Error(`Failed to update validators: ${vaResponse.status}`); 361 | } 362 | 363 | if (VERBOSE_LOG) { 364 | console.log( 365 | `VA Response ${vaResponse.status} ${JSON.stringify( 366 | vaResponse.data 367 | )}` 368 | ); 369 | } 370 | } 371 | 372 | // Reset the try counter 373 | tryCount = 0; 374 | } catch (e) { 375 | console.log(`ERROR`); 376 | console.log(e); 377 | if (++tryCount === MAX_TRIES) throw e; 378 | } 379 | } 380 | } 381 | 382 | Promise.all([ 383 | watchBlockhash(gBlockhash, rpcConnection), 384 | watchSlotSent(gSlotSent, rpcSubscriptions), 385 | pingThing(), 386 | ]); 387 | -------------------------------------------------------------------------------- /ping-thing-client-token.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createTransactionMessage, 3 | pipe, 4 | setTransactionMessageFeePayer, 5 | setTransactionMessageLifetimeUsingBlockhash, 6 | createKeyPairFromBytes, 7 | getAddressFromPublicKey, 8 | signTransaction, 9 | appendTransactionMessageInstructions, 10 | sendTransactionWithoutConfirmingFactory, 11 | createSolanaRpcSubscriptions_UNSTABLE, 12 | getSignatureFromTransaction, 13 | compileTransaction, 14 | createSolanaRpc, 15 | SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND, 16 | address, 17 | isSolanaError, 18 | type Signature, 19 | type Commitment, 20 | sendAndConfirmTransactionFactory, 21 | SOLANA_ERROR__BLOCK_HEIGHT_EXCEEDED, 22 | } from "@solana/kit"; 23 | import dotenv from "dotenv"; 24 | import bs58 from "bs58"; 25 | import { getSetComputeUnitLimitInstruction, getSetComputeUnitPriceInstruction } from "@solana-program/compute-budget"; 26 | import { createRecentSignatureConfirmationPromiseFactory } from "@solana/transaction-confirmation"; 27 | import { sleep } from "./utils/misc.js"; 28 | import { watchBlockhash } from "./utils/blockhash.js"; 29 | import { watchSlotSent } from "./utils/slot.js"; 30 | import { setMaxListeners } from "events"; 31 | import axios from "axios"; 32 | import { getTransferInstruction } from "@solana-program/token"; 33 | import { safeRace } from "@solana/promises"; 34 | 35 | dotenv.config(); 36 | 37 | const orignalConsoleLog = console.log; 38 | console.log = function (...message) { 39 | const dateTime = new Date().toUTCString(); 40 | orignalConsoleLog(dateTime, ...message); 41 | }; 42 | 43 | // Catch interrupts & exit 44 | process.on("SIGINT", function () { 45 | console.log(`Caught interrupt signal`, "\n"); 46 | process.exit(); 47 | }); 48 | 49 | const RPC_ENDPOINT = process.env.RPC_ENDPOINT; 50 | const WS_ENDPOINT = process.env.WS_ENDPOINT; 51 | 52 | const SLEEP_MS_RPC = process.env.SLEEP_MS_RPC ? parseInt(process.env.SLEEP_MS_RPC) : 2000; 53 | const SLEEP_MS_LOOP = process.env.SLEEP_MS_LOOP ? parseInt(process.env.SLEEP_MS_LOOP) : 0; 54 | const VA_API_KEY = process.env.VA_API_KEY; 55 | const VERBOSE_LOG = process.env.VERBOSE_LOG === "true" ? true : false; 56 | const COMMITMENT_LEVEL = process.env.COMMITMENT || "confirmed"; 57 | const USE_PRIORITY_FEE = process.env.USE_PRIORITY_FEE == "true" ? true : false; 58 | 59 | // if USE_PRIORITY_FEE is set, read and set the fee value, otherwise set it to 0 60 | const PRIORITY_FEE_MICRO_LAMPORTS = USE_PRIORITY_FEE ? process.env.PRIORITY_FEE_MICRO_LAMPORTS || 5000 : 0 61 | 62 | const SKIP_VALIDATORS_APP = process.env.SKIP_VALIDATORS_APP || false; 63 | const ATA_SEND = address(process.env.ATA_SEND!); 64 | const ATA_REC = address(process.env.ATA_REC!); 65 | const ATA_AMT = BigInt(process.env.ATA_AMT!); 66 | 67 | if (VERBOSE_LOG) console.log(`Starting script`); 68 | 69 | // RPC connection for HTTP API calls, equivalent to `const c = new Connection(RPC_ENDPOINT)` 70 | const rpcConnection = createSolanaRpc(RPC_ENDPOINT!); 71 | 72 | // RPC connection for websocket connection 73 | const rpcSubscriptions = createSolanaRpcSubscriptions_UNSTABLE(WS_ENDPOINT!); 74 | 75 | let USER_KEYPAIR; 76 | const TX_RETRY_INTERVAL = 2000; 77 | 78 | // Global blockhash value fetching constantly in a loop 79 | const gBlockhash = { value: null, updated_at: 0, lastValidBlockHeight: BigInt(0) }; 80 | 81 | // Record new slot on `firstShredReceived` fetched from a slot subscription 82 | const gSlotSent = { value: null, updated_at: 0 }; 83 | 84 | // main ping thing function 85 | async function pingThing() { 86 | USER_KEYPAIR = await createKeyPairFromBytes( 87 | bs58.decode(process.env.WALLET_PRIVATE_KEYPAIR!) 88 | ); 89 | // Pre-define loop constants & variables 90 | const FAKE_SIGNATURE = 91 | "9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999"; 92 | 93 | // Run inside a loop that will exit after 3 consecutive failures 94 | const MAX_TRIES = 3; 95 | let tryCount = 0; 96 | 97 | const feePayer = await getAddressFromPublicKey(USER_KEYPAIR.publicKey); 98 | 99 | // Infinite loop to keep this running forever 100 | while (true) { 101 | await sleep(SLEEP_MS_LOOP); 102 | 103 | let blockhash; 104 | let lastValidBlockHeight; 105 | let slotSent; 106 | let slotLanded; 107 | let signature; 108 | let txStart; 109 | let txSendAttempts = 1; 110 | 111 | // Wait for fresh slot and blockhash 112 | while (true) { 113 | if ( 114 | Date.now() - gBlockhash.updated_at < 10000 && 115 | Date.now() - gSlotSent.updated_at < 50 116 | ) { 117 | blockhash = gBlockhash.value; 118 | lastValidBlockHeight = gBlockhash.lastValidBlockHeight; 119 | slotSent = gSlotSent.value; 120 | break; 121 | } 122 | 123 | await sleep(1); 124 | } 125 | 126 | try { 127 | try { 128 | const authorityAddress = await getAddressFromPublicKey(USER_KEYPAIR.publicKey); 129 | 130 | // Pipe multiple instructions in a tx 131 | // Names are self-explainatory. See the imports of these functions 132 | const transaction = pipe( 133 | createTransactionMessage({ version: 0 }), 134 | (tx) => setTransactionMessageFeePayer(feePayer, tx), 135 | (tx) => 136 | setTransactionMessageLifetimeUsingBlockhash( 137 | { 138 | blockhash: gBlockhash.value!, 139 | lastValidBlockHeight: BigInt(gBlockhash.lastValidBlockHeight!), 140 | }, 141 | tx 142 | ), 143 | (tx) => 144 | appendTransactionMessageInstructions( 145 | [ 146 | getSetComputeUnitLimitInstruction({ 147 | units: 5000, 148 | }), 149 | getSetComputeUnitPriceInstruction({ microLamports: BigInt(PRIORITY_FEE_MICRO_LAMPORTS) }), 150 | 151 | // Token transfer instruction 152 | getTransferInstruction({ 153 | amount: ATA_AMT, 154 | destination: ATA_REC, 155 | source: ATA_SEND, 156 | authority: authorityAddress 157 | }) 158 | ], 159 | tx 160 | ) 161 | 162 | ); 163 | 164 | // Sign the tx 165 | const transactionSignedWithFeePayer = await signTransaction( 166 | [USER_KEYPAIR], 167 | compileTransaction(transaction) 168 | ); 169 | 170 | // Get the tx signature 171 | signature = getSignatureFromTransaction(transactionSignedWithFeePayer); 172 | 173 | 174 | // Note the timestamp we begin sending the tx, we'll compare it with the 175 | // timestamp when the tx is confirmed to mesaure the tx latency 176 | txStart = Date.now(); 177 | 178 | console.log(`Sending ${signature}`); 179 | 180 | // The tx sendinng and confirming startegy of the Ping Thing is as follow: 181 | // 1. Send the tranaction 182 | // 2. Subscribe to the tx signature and listen for dersied commitment change 183 | // 3. Send the tx again if not confrmed within 2000ms 184 | // 4. Stop sending when tx is confirmed 185 | 186 | // Create a sender factory that sends a transaction and doesn't wait for confirmation 187 | const mSendTransaction = sendTransactionWithoutConfirmingFactory({ 188 | rpc: rpcConnection, 189 | }); 190 | 191 | // Incase we want to abort the promise that's waiting for a tx to be confirmed 192 | const abortController = new AbortController(); 193 | 194 | while (true) { 195 | const sendAndConfirmTransaction = sendAndConfirmTransactionFactory({ rpc: rpcConnection, rpcSubscriptions }); 196 | 197 | try { 198 | await safeRace([ 199 | sendAndConfirmTransaction(transactionSignedWithFeePayer, { maxRetries: 0n, skipPreflight: true, commitment: "confirmed", abortSignal: abortController.signal }), 200 | sleep(TX_RETRY_INTERVAL).then(() => { 201 | throw new Error("TxSendTimeout"); 202 | }), 203 | ]); 204 | 205 | console.log(`Confirmed tx ${signature}`); 206 | 207 | break; 208 | } catch (e: any) { 209 | if (e.message === "TxSendTimeout") { 210 | console.log(`Tx not confirmed after ${TX_RETRY_INTERVAL * txSendAttempts++}ms, resending`) 211 | continue; 212 | } else if (isSolanaError(e, SOLANA_ERROR__BLOCK_HEIGHT_EXCEEDED)) { 213 | throw new Error("TransactionExpiredBlockheightExceededError") 214 | } else { 215 | throw e; 216 | } 217 | 218 | } 219 | 220 | } 221 | 222 | } catch (e: any) { 223 | // Log and loop if we get a bad blockhash. 224 | if ( 225 | isSolanaError(e, SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND) 226 | ) { 227 | // if (e.message.includes("Blockhash not found")) { 228 | console.log(`ERROR: Blockhash not found`); 229 | continue; 230 | } 231 | 232 | // If the transaction expired on the chain. Make a log entry and send 233 | // to VA. Otherwise log and loop. 234 | if (e.name === "TransactionExpiredBlockheightExceededError") { 235 | console.log( 236 | `ERROR: Blockhash expired/block height exceeded. TX failure sent to VA.` 237 | ); 238 | } else { 239 | console.log(`ERROR: ${e.name}`); 240 | console.log(e.message); 241 | console.log(e); 242 | console.log(JSON.stringify(e)); 243 | continue; 244 | } 245 | 246 | // Need to submit a fake signature to pass the import filters 247 | signature = FAKE_SIGNATURE as Signature; 248 | } 249 | 250 | const txEnd = Date.now(); 251 | // Sleep a little here to ensure the signature is on an RPC node. 252 | await sleep(SLEEP_MS_RPC); 253 | if (signature !== FAKE_SIGNATURE) { 254 | // Capture the slotLanded 255 | let txLanded = await rpcConnection 256 | .getTransaction(signature, { 257 | commitment: COMMITMENT_LEVEL as Commitment, 258 | maxSupportedTransactionVersion: 0, 259 | }) 260 | .send(); 261 | if (txLanded === null) { 262 | console.log( 263 | signature, 264 | `ERROR: tx is not found on RPC within ${SLEEP_MS_RPC}ms. Not sending to VA.` 265 | ); 266 | continue; 267 | } 268 | slotLanded = txLanded.slot; 269 | } 270 | 271 | // Don't send if the slot latency is negative 272 | if (slotLanded! < slotSent!) { 273 | console.log( 274 | signature, 275 | `ERROR: Slot ${slotLanded} < ${slotSent}. Not sending to VA.` 276 | ); 277 | continue; 278 | } 279 | 280 | // prepare the payload to send to validators.app 281 | const vAPayload = JSON.stringify({ 282 | time: txEnd - txStart!, 283 | signature, 284 | transaction_type: "transfer", 285 | success: signature !== FAKE_SIGNATURE, 286 | application: "web3", 287 | commitment_level: COMMITMENT_LEVEL, 288 | slot_sent: BigInt(slotSent!).toString(), 289 | slot_landed: BigInt(slotLanded!).toString(), 290 | }); 291 | if (VERBOSE_LOG) { 292 | console.log(vAPayload); 293 | } 294 | 295 | if (!SKIP_VALIDATORS_APP) { 296 | // Send the payload to validators.app 297 | const vaResponse = await axios.post( 298 | "https://www.validators.app/api/v1/ping-thing/mainnet", 299 | vAPayload, 300 | { 301 | headers: { 302 | "Content-Type": "application/json", 303 | Token: VA_API_KEY, 304 | }, 305 | } 306 | ); 307 | // throw error if response is not ok 308 | if (!(vaResponse.status >= 200 && vaResponse.status <= 299)) { 309 | throw new Error(`Failed to update validators: ${vaResponse.status}`); 310 | } 311 | 312 | if (VERBOSE_LOG) { 313 | console.log( 314 | `VA Response ${vaResponse.status} ${JSON.stringify( 315 | vaResponse.data 316 | )}` 317 | ); 318 | } 319 | } 320 | 321 | // Reset the try counter 322 | tryCount = 0; 323 | } catch (e) { 324 | console.log(`ERROR`); 325 | console.log(e); 326 | if (++tryCount === MAX_TRIES) throw e; 327 | } 328 | } 329 | } 330 | 331 | Promise.all([ 332 | watchBlockhash(gBlockhash, rpcConnection), 333 | watchSlotSent(gSlotSent, rpcSubscriptions), 334 | pingThing(), 335 | ]); 336 | -------------------------------------------------------------------------------- /ping-thing-client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createTransactionMessage, 3 | pipe, 4 | setTransactionMessageFeePayer, 5 | setTransactionMessageLifetimeUsingBlockhash, 6 | createKeyPairFromBytes, 7 | getAddressFromPublicKey, 8 | createSignerFromKeyPair, 9 | signTransaction, 10 | appendTransactionMessageInstructions, 11 | sendTransactionWithoutConfirmingFactory, 12 | createSolanaRpcSubscriptions_UNSTABLE, 13 | getSignatureFromTransaction, 14 | compileTransaction, 15 | createSolanaRpc, 16 | SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND, 17 | isSolanaError, 18 | type Signature, 19 | type Commitment, 20 | sendAndConfirmTransactionFactory, 21 | SOLANA_ERROR__BLOCK_HEIGHT_EXCEEDED, 22 | createRpc, 23 | createDefaultRpcTransport, 24 | } from "@solana/kit"; 25 | import dotenv from "dotenv"; 26 | import bs58 from "bs58"; 27 | import { 28 | getSetComputeUnitLimitInstruction, 29 | getSetComputeUnitPriceInstruction, 30 | } from "@solana-program/compute-budget"; 31 | import { getTransferSolInstruction } from "@solana-program/system"; 32 | import { sleep } from "./utils/misc.js"; 33 | import { watchBlockhash } from "./utils/blockhash.js"; 34 | import { watchSlotSent } from "./utils/slot.js"; 35 | import { setMaxListeners } from "events"; 36 | import axios from "axios"; 37 | import { safeRace } from "@solana/promises"; 38 | import { 39 | confirmationLatency, 40 | initPrometheus, 41 | slotLatency, 42 | } from "./utils/prometheus.js"; 43 | import { customizedRpcApi } from "./utils/grpfCustomRpcApi.js"; 44 | 45 | dotenv.config(); 46 | 47 | const orignalConsoleLog = console.log; 48 | console.log = function (...message) { 49 | const dateTime = new Date().toUTCString(); 50 | orignalConsoleLog(dateTime, ...message); 51 | }; 52 | 53 | // Catch interrupts & exit 54 | process.on("SIGINT", function () { 55 | console.log(`Caught interrupt signal`, "\n"); 56 | process.exit(); 57 | }); 58 | 59 | const RPC_ENDPOINT = process.env.RPC_ENDPOINT; 60 | const WS_ENDPOINT = process.env.WS_ENDPOINT; 61 | 62 | const SLEEP_MS_RPC = process.env.SLEEP_MS_RPC 63 | ? parseInt(process.env.SLEEP_MS_RPC) 64 | : 2000; 65 | const SLEEP_MS_LOOP = process.env.SLEEP_MS_LOOP 66 | ? parseInt(process.env.SLEEP_MS_LOOP) 67 | : 0; 68 | const VA_API_KEY = process.env.VA_API_KEY; 69 | const VERBOSE_LOG = process.env.VERBOSE_LOG === "true" ? true : false; 70 | const COMMITMENT_LEVEL = process.env.COMMITMENT || "confirmed"; 71 | const USE_PRIORITY_FEE = process.env.USE_PRIORITY_FEE == "true" ? true : false; 72 | 73 | // if USE_PRIORITY_FEE is set, read and set the fee value, otherwise set it to 0 74 | const PRIORITY_FEE_MICRO_LAMPORTS = USE_PRIORITY_FEE 75 | ? process.env.PRIORITY_FEE_MICRO_LAMPORTS || 5000 76 | : 0; 77 | const PRIORITY_FEE_PERCENTILE = parseInt( 78 | `${USE_PRIORITY_FEE ? process.env.PRIORITY_FEE_PERCENTILE || 5000 : 0}` 79 | ); 80 | 81 | const PINGER_REGION = process.env.PINGER_REGION!; 82 | 83 | const SKIP_VALIDATORS_APP = process.env.SKIP_VALIDATORS_APP || false; 84 | const SKIP_PROMETHEUS = process.env.SKIP_PROMETHEUS || false; 85 | 86 | const PINGER_NAME = process.env.PINGER_NAME || "UNSET"; 87 | 88 | if (VERBOSE_LOG) console.log(`Starting script`); 89 | 90 | // RPC connection for HTTP API calls, equivalent to `const c = new Connection(RPC_ENDPOINT)` 91 | // const rpcConnection = createSolanaRpc(RPC_ENDPOINT!); 92 | const rpcConnection = createRpc({ 93 | api: customizedRpcApi, 94 | transport: createDefaultRpcTransport({ url: RPC_ENDPOINT! }), 95 | }); 96 | 97 | // RPC connection for websocket connection 98 | const rpcSubscriptions = createSolanaRpcSubscriptions_UNSTABLE(WS_ENDPOINT!); 99 | 100 | let USER_KEYPAIR; 101 | const TX_RETRY_INTERVAL = 2000; 102 | 103 | // Global blockhash value fetching constantly in a loop 104 | const gBlockhash = { 105 | value: null, 106 | updated_at: 0, 107 | lastValidBlockHeight: BigInt(0), 108 | }; 109 | 110 | // Record new slot on `firstShredReceived` fetched from a slot subscription 111 | const gSlotSent = { value: null, updated_at: 0 }; 112 | 113 | // main ping thing function 114 | async function pingThing() { 115 | USER_KEYPAIR = await createKeyPairFromBytes( 116 | bs58.decode(process.env.WALLET_PRIVATE_KEYPAIR!) 117 | ); 118 | // Pre-define loop constants & variables 119 | const FAKE_SIGNATURE = 120 | "9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999"; 121 | 122 | // Run inside a loop that will exit after 3 consecutive failures 123 | const MAX_TRIES = 3; 124 | let tryCount = 0; 125 | 126 | const feePayer = await getAddressFromPublicKey(USER_KEYPAIR.publicKey); 127 | const signer = await createSignerFromKeyPair(USER_KEYPAIR); 128 | 129 | // Infinite loop to keep this running forever 130 | while (true) { 131 | await sleep(SLEEP_MS_LOOP); 132 | 133 | let blockhash; 134 | let lastValidBlockHeight; 135 | let slotSent; 136 | let slotLanded; 137 | let signature; 138 | let txStart; 139 | let txSendAttempts = 1; 140 | let priorityFeeMicroLamports = 0; 141 | 142 | // Wait for fresh slot and blockhash 143 | while (true) { 144 | if ( 145 | Date.now() - gBlockhash.updated_at < 10000 && 146 | Date.now() - gSlotSent.updated_at < 50 147 | ) { 148 | blockhash = gBlockhash.value; 149 | lastValidBlockHeight = gBlockhash.lastValidBlockHeight; 150 | slotSent = gSlotSent.value; 151 | break; 152 | } 153 | 154 | await sleep(1); 155 | } 156 | 157 | try { 158 | try { 159 | if (USE_PRIORITY_FEE) { 160 | const feeResults = await rpcConnection 161 | .getRecentPrioritizationFeesTriton([], { 162 | percentile: PRIORITY_FEE_PERCENTILE, 163 | }) 164 | .send(); 165 | 166 | if (feeResults && feeResults.length > 0) { 167 | const fees: number[] = []; 168 | for (let i = 0; i < feeResults.length; i++) { 169 | fees.push(feeResults[i].prioritizationFee); 170 | } 171 | 172 | const sortedFeesArray = fees.sort(); 173 | 174 | priorityFeeMicroLamports = 175 | sortedFeesArray[sortedFeesArray.length - 1]; 176 | } 177 | } 178 | 179 | console.log(`Fees ${priorityFeeMicroLamports}`); 180 | 181 | // Pipe multiple instructions in a tx 182 | // Names are self-explainatory. See the imports of these functions 183 | const transaction = pipe( 184 | createTransactionMessage({ version: 0 }), 185 | (tx) => setTransactionMessageFeePayer(feePayer, tx), 186 | (tx) => 187 | setTransactionMessageLifetimeUsingBlockhash( 188 | { 189 | blockhash: gBlockhash.value!, 190 | lastValidBlockHeight: BigInt(gBlockhash.lastValidBlockHeight!), 191 | }, 192 | tx 193 | ), 194 | (tx) => 195 | appendTransactionMessageInstructions( 196 | [ 197 | getSetComputeUnitLimitInstruction({ 198 | units: 500, 199 | }), 200 | getSetComputeUnitPriceInstruction({ 201 | microLamports: BigInt(priorityFeeMicroLamports), 202 | }), 203 | 204 | // SOL transfer instruction 205 | getTransferSolInstruction({ 206 | // @ts-ignore 207 | source: signer, 208 | // @ts-ignore 209 | destination: feePayer, 210 | amount: 5000, 211 | }), 212 | ], 213 | tx 214 | ) 215 | ); 216 | 217 | // Sign the tx 218 | const transactionSignedWithFeePayer = await signTransaction( 219 | [USER_KEYPAIR], 220 | compileTransaction(transaction) 221 | ); 222 | 223 | // Get the tx signature 224 | signature = getSignatureFromTransaction(transactionSignedWithFeePayer); 225 | 226 | // Note the timestamp we begin sending the tx, we'll compare it with the 227 | // timestamp when the tx is confirmed to mesaure the tx latency 228 | txStart = Date.now(); 229 | 230 | console.log(`Sending ${signature}`); 231 | 232 | // The tx sendinng and confirming startegy of the Ping Thing is as follow: 233 | // 1. Send the tranaction 234 | // 2. Subscribe to the tx signature and listen for dersied commitment change 235 | // 3. Send the tx again if not confrmed within 2000ms 236 | // 4. Stop sending when tx is confirmed 237 | 238 | // Create a sender factory that sends a transaction and doesn't wait for confirmation 239 | const mSendTransaction = sendTransactionWithoutConfirmingFactory({ 240 | rpc: rpcConnection, 241 | }); 242 | 243 | // Incase we want to abort the promise that's waiting for a tx to be confirmed 244 | const abortController = new AbortController(); 245 | 246 | while (true) { 247 | const sendAndConfirmTransaction = sendAndConfirmTransactionFactory({ 248 | rpc: rpcConnection, 249 | rpcSubscriptions, 250 | }); 251 | 252 | try { 253 | await safeRace([ 254 | sendAndConfirmTransaction(transactionSignedWithFeePayer, { 255 | maxRetries: 0n, 256 | skipPreflight: true, 257 | commitment: "confirmed", 258 | abortSignal: abortController.signal, 259 | }), 260 | sleep(TX_RETRY_INTERVAL).then(() => { 261 | throw new Error("TxSendTimeout"); 262 | }), 263 | ]); 264 | 265 | console.log(`Confirmed tx ${signature}`); 266 | 267 | break; 268 | } catch (e: any) { 269 | if (e.message === "TxSendTimeout") { 270 | console.log( 271 | `Tx not confirmed after ${ 272 | TX_RETRY_INTERVAL * txSendAttempts++ 273 | }ms, resending` 274 | ); 275 | continue; 276 | } else if (isSolanaError(e, SOLANA_ERROR__BLOCK_HEIGHT_EXCEEDED)) { 277 | throw new Error("TransactionExpiredBlockheightExceededError"); 278 | } else { 279 | throw e; 280 | } 281 | } 282 | } 283 | } catch (e: any) { 284 | // Log and loop if we get a bad blockhash. 285 | if ( 286 | isSolanaError(e, SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND) 287 | ) { 288 | // if (e.message.includes("Blockhash not found")) { 289 | console.log(`ERROR: Blockhash not found`); 290 | continue; 291 | } 292 | 293 | // If the transaction expired on the chain. Make a log entry and send 294 | // to VA. Otherwise log and loop. 295 | if (e.name === "TransactionExpiredBlockheightExceededError") { 296 | console.log( 297 | `ERROR: Blockhash expired/block height exceeded. TX failure sent to VA.` 298 | ); 299 | } else { 300 | console.log(`ERROR: ${e.name}`); 301 | console.log(e.message); 302 | console.log(e); 303 | console.log(JSON.stringify(e)); 304 | continue; 305 | } 306 | 307 | // Need to submit a fake signature to pass the import filters 308 | signature = FAKE_SIGNATURE as Signature; 309 | } 310 | 311 | const txEnd = Date.now(); 312 | // Sleep a little here to ensure the signature is on an RPC node. 313 | await sleep(SLEEP_MS_RPC); 314 | if (signature !== FAKE_SIGNATURE) { 315 | // Capture the slotLanded 316 | let txLanded = await rpcConnection 317 | .getTransaction(signature, { 318 | commitment: COMMITMENT_LEVEL as Commitment, 319 | maxSupportedTransactionVersion: 0, 320 | }) 321 | .send(); 322 | if (txLanded === null) { 323 | console.log( 324 | signature, 325 | `ERROR: tx is not found on RPC within ${SLEEP_MS_RPC}ms. Not sending to VA.` 326 | ); 327 | continue; 328 | } 329 | slotLanded = txLanded.slot; 330 | } 331 | 332 | // Don't send if the slot latency is negative 333 | if (slotLanded! < slotSent!) { 334 | console.log( 335 | signature, 336 | `ERROR: Slot ${slotLanded} < ${slotSent}. Not sending to VA.` 337 | ); 338 | continue; 339 | } 340 | 341 | // prepare the payload to send to validators.app 342 | const vAPayload = JSON.stringify({ 343 | time: txEnd - txStart!, 344 | signature, 345 | transaction_type: "transfer", 346 | success: signature !== FAKE_SIGNATURE, 347 | application: "web3", 348 | commitment_level: COMMITMENT_LEVEL, 349 | slot_sent: BigInt(slotSent!).toString(), 350 | slot_landed: BigInt(slotLanded!).toString(), 351 | priority_fee_percentile: Math.floor(PRIORITY_FEE_PERCENTILE / 100), 352 | priority_fee_micro_lamports: `${priorityFeeMicroLamports}`, 353 | pinger_region: PINGER_REGION, 354 | }); 355 | if (VERBOSE_LOG) { 356 | console.log(vAPayload); 357 | } 358 | 359 | if (!SKIP_VALIDATORS_APP) { 360 | // Send the payload to validators.app 361 | const vaResponse = await axios.post( 362 | "https://www.validators.app/api/v1/ping-thing/mainnet", 363 | vAPayload, 364 | { 365 | headers: { 366 | "Content-Type": "application/json", 367 | Token: VA_API_KEY, 368 | }, 369 | } 370 | ); 371 | // throw error if response is not ok 372 | if (!(vaResponse.status >= 200 && vaResponse.status <= 299)) { 373 | throw new Error(`Failed to update validators: ${vaResponse.status}`); 374 | } 375 | 376 | if (VERBOSE_LOG) { 377 | console.log( 378 | `VA Response ${vaResponse.status} ${JSON.stringify( 379 | vaResponse.data 380 | )}` 381 | ); 382 | } 383 | } 384 | 385 | if (!SKIP_PROMETHEUS) { 386 | confirmationLatency.observe( 387 | { 388 | pinger_name: PINGER_NAME, 389 | }, 390 | txEnd - txStart! 391 | ); 392 | 393 | slotLatency.observe( 394 | { 395 | pinger_name: PINGER_NAME, 396 | }, 397 | Number(slotLanded! - slotSent!) 398 | ); 399 | } 400 | 401 | // Reset the try counter 402 | tryCount = 0; 403 | } catch (e) { 404 | console.log(`ERROR`); 405 | console.log(e); 406 | if (++tryCount === MAX_TRIES) throw e; 407 | } 408 | } 409 | } 410 | 411 | Promise.all([ 412 | watchBlockhash(gBlockhash, rpcConnection), 413 | watchSlotSent(gSlotSent, rpcSubscriptions), 414 | initPrometheus(), 415 | pingThing(), 416 | ]); 417 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } -------------------------------------------------------------------------------- /utils/blockhash.ts: -------------------------------------------------------------------------------- 1 | // This is a blockhash watcher. It constantly fetches new blockhash and updates a global variable 2 | 3 | import type { Blockhash, GetLatestBlockhashApi, Rpc, SolanaRpcApi } from "@solana/web3.js"; 4 | import { sleep } from "./misc.js"; 5 | import { safeRace } from "@solana/promises"; 6 | 7 | const MAX_BLOCKHASH_FETCH_ATTEMPTS = process.env.MAX_BLOCKHASH_FETCH_ATTEMPTS ? 8 | parseInt(process.env.MAX_BLOCKHASH_FETCH_ATTEMPTS) : 5; 9 | let attempts = 0; 10 | 11 | export const watchBlockhash = async (gBlockhash: { value: Blockhash | null, updated_at: number, lastValidBlockHeight: bigint }, connection: Rpc) => { 12 | 13 | while (true) { 14 | try { 15 | // Use a 5 second timeout to avoid hanging the script 16 | const timeoutPromise = new Promise((_, reject) => 17 | setTimeout( 18 | () => 19 | reject( 20 | new Error( 21 | `${new Date().toISOString()} ERROR: Blockhash fetch operation timed out` 22 | ) 23 | ), 24 | 5000 25 | ) 26 | ); 27 | // Get the latest blockhash from the RPC node and update the global 28 | // blockhash object with the new value and timestamp. If the RPC node 29 | // fails to respond within 5 seconds, the promise will reject and the 30 | // script will log an error. 31 | const latestBlockhash = await safeRace([ 32 | connection.getLatestBlockhash().send(), 33 | timeoutPromise, 34 | ]) as ReturnType; 35 | 36 | gBlockhash.value = latestBlockhash.value.blockhash; 37 | gBlockhash.lastValidBlockHeight = 38 | latestBlockhash.value.lastValidBlockHeight; 39 | 40 | gBlockhash.updated_at = Date.now(); 41 | attempts = 0; 42 | 43 | } catch (error: any) { 44 | gBlockhash.value = null; 45 | gBlockhash.updated_at = 0; 46 | 47 | ++attempts; 48 | 49 | if (error.message.includes("new blockhash")) { 50 | console.log( 51 | `${new Date().toISOString()} ERROR: Unable to obtain a new blockhash` 52 | ); 53 | } else { 54 | console.log(`${new Date().toISOString()} BLOCKHASH FETCH ERROR: ${error.name}`); 55 | console.log(error.message); 56 | console.log(error); 57 | console.log(JSON.stringify(error)); 58 | } 59 | } finally { 60 | if (attempts >= MAX_BLOCKHASH_FETCH_ATTEMPTS) { 61 | console.log( 62 | `${new Date().toISOString()} ERROR: Max attempts for fetching blockhash reached, exiting` 63 | ); 64 | process.exit(0); 65 | } 66 | } 67 | 68 | await sleep(5000); 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /utils/grpfCustomRpcApi.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Address, 3 | createRpcMessage, 4 | type RpcApi, 5 | type RpcPlan, 6 | createSolanaRpcApi, 7 | DEFAULT_RPC_CONFIG, 8 | type SolanaRpcApiMainnet, 9 | } from "@solana/kit"; 10 | 11 | // Define the response type for prioritization fees 12 | type PrioritizationFeeResult = { 13 | slot: number; 14 | prioritizationFee: number; 15 | }; 16 | 17 | // Define our custom API interface 18 | type PrioritizationFeesApi = { 19 | getRecentPrioritizationFeesTriton( 20 | addresses?: Address[], 21 | options?: { percentile?: number } 22 | ): Promise; 23 | }; 24 | 25 | // Create the customized RPC API 26 | const solanaRpcApi = 27 | createSolanaRpcApi(DEFAULT_RPC_CONFIG); 28 | 29 | const customizedRpcApi = new Proxy(solanaRpcApi, { 30 | defineProperty() { 31 | return false; 32 | }, 33 | deleteProperty() { 34 | return false; 35 | }, 36 | get(target, p, receiver): any { 37 | const methodName = p.toString(); 38 | if (methodName === "getRecentPrioritizationFeesTriton") { 39 | return ( 40 | addresses: Address[] = [], 41 | options: { percentile?: number } = {} 42 | ) => { 43 | const request = { 44 | methodName: "getRecentPrioritizationFees", 45 | params: [ 46 | addresses, 47 | { 48 | percentile: options.percentile || 0, 49 | }, 50 | ], 51 | }; 52 | 53 | return { 54 | // @ts-ignore 55 | execute: async ({ signal, transport }) => { 56 | const response = await transport({ 57 | payload: createRpcMessage(request), 58 | signal, 59 | }); 60 | return response.result as PrioritizationFeeResult[]; 61 | }, 62 | }; 63 | }; 64 | } else { 65 | return Reflect.get(target, p, receiver); 66 | } 67 | }, 68 | }) as RpcApi; 69 | 70 | export { customizedRpcApi, type PrioritizationFeesApi }; 71 | -------------------------------------------------------------------------------- /utils/misc.ts: -------------------------------------------------------------------------------- 1 | export const sleep = async (duration: number) => 2 | await new Promise((resolve) => setTimeout(resolve, duration)); 3 | 4 | // logged statements will have UTC timestamp prepended 5 | const orignalConsoleLog = console.log; 6 | console.log = function (...message) { 7 | const dateTime = new Date().toUTCString(); 8 | orignalConsoleLog(dateTime, ...message); 9 | }; -------------------------------------------------------------------------------- /utils/prometheus.ts: -------------------------------------------------------------------------------- 1 | import { Registry, Histogram } from 'prom-client'; 2 | import express from 'express'; 3 | import dotenv from "dotenv" 4 | dotenv.config() 5 | 6 | const METRICS_PORT = process.env.PROMETHEUS_PORT || 9090; 7 | 8 | // Initialize Prometheus registry 9 | const register = new Registry(); 10 | 11 | // Generate millisecond buckets from 0 to 10000 12 | const generateBuckets = () => { 13 | const buckets = []; 14 | // Fine granularity for 0-1000ms range (50ms steps) 15 | for (let i = 0; i <= 1000; i += 50) buckets.push(i); 16 | // Medium granularity 1000-2000ms (100ms steps) 17 | for (let i = 1100; i <= 2000; i += 100) buckets.push(i); 18 | // Coarse granularity 2000-10000ms (200ms steps) 19 | for (let i = 2200; i <= 10000; i += 200) buckets.push(i); 20 | return buckets; 21 | }; 22 | 23 | // Create histograms 24 | export const confirmationLatency = new Histogram({ 25 | name: 'ping_thing_client_confirmation_latency', 26 | help: 'Solana transaction confirmation latency in milliseconds', 27 | labelNames: ['pinger_name'], 28 | buckets: generateBuckets(), 29 | registers: [register] 30 | }); 31 | 32 | export const slotLatency = new Histogram({ 33 | name: 'ping_thing_client_slot_latency', 34 | help: 'Difference between landed slot and sent slot', 35 | labelNames: ['pinger_name'], 36 | buckets: Array.from({ length: 30 }, (_, i) => i + 1), 37 | registers: [register] 38 | }); 39 | 40 | export async function initPrometheus() { 41 | const app = express(); 42 | 43 | app.get('/metrics', async (req, res) => { 44 | res.set('Content-Type', register.contentType); 45 | res.end(await register.metrics()); 46 | }); 47 | 48 | app.listen(METRICS_PORT, () => { 49 | console.log(`Metrics server listening on port ${METRICS_PORT}`); 50 | }); 51 | } -------------------------------------------------------------------------------- /utils/slot.ts: -------------------------------------------------------------------------------- 1 | // This is a slot watcher. It constantly subscribes to slot changes and updates a global variable 2 | 3 | import type { RpcSubscriptions, SlotsUpdatesNotificationsApi } from "@solana/web3.js"; 4 | import { sleep } from "./misc.js"; 5 | 6 | const MAX_SLOT_FETCH_ATTEMPTS = process.env.MAX_SLOT_FETCH_ATTEMPTS ? parseInt(process.env.MAX_SLOT_FETCH_ATTEMPTS) : 100; 7 | let attempts = 0; 8 | 9 | // The 2.0 SDK lets us know when a connection is disconnected and we can reconnect 10 | // But we want to wait for some time to give the server some timet to recover and not hammer it with infinite retry requests 11 | // Aggressive reconnects will keep your script stuck in a error loop and consume CPU 12 | const SLOTS_SUBSCRIPTION_DELAY = process.env.SLOTS_SUBSCRIPTION_DELAY ? parseInt(process.env.SLOTS_SUBSCRIPTION_DELAY) : 4000; 13 | 14 | export const watchSlotSent = async (gSlotSent: { value: bigint | null, updated_at: number }, rpcSubscription: RpcSubscriptions) => { 15 | while (true) { 16 | const abortController = new AbortController(); 17 | try { 18 | 19 | // Subscribing to the `slotsUpdatesSubscribe` and update slot number 20 | // https://solana.com/docs/rpc/websocket/slotsupdatessubscribe 21 | const slotNotifications = await rpcSubscription 22 | .slotsUpdatesNotifications() 23 | .subscribe({ abortSignal: abortController.signal }); 24 | 25 | // handling subscription updates via `AsyncIterators` 26 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncIterator 27 | for await (const notification of slotNotifications) { 28 | if ( 29 | notification.type === "firstShredReceived" || 30 | notification.type === "completed" 31 | ) { 32 | gSlotSent.value = notification.slot; 33 | gSlotSent.updated_at = Date.now(); 34 | attempts = 0; 35 | continue; 36 | } 37 | 38 | gSlotSent.value = null; 39 | gSlotSent.updated_at = 0; 40 | 41 | ++attempts; 42 | 43 | // If the RPC is not sending the updates we want, erorr out and crash 44 | if (attempts >= MAX_SLOT_FETCH_ATTEMPTS) { 45 | console.log( 46 | `ERROR: Max attempts for fetching slot type "firstShredReceived" or "completed" reached, exiting` 47 | ); 48 | process.exit(0); 49 | } 50 | 51 | // If update not received in last 3s, re-subscribe 52 | if (gSlotSent.value !== null) { 53 | while (Date.now() - gSlotSent.updated_at < 3000) { 54 | await sleep(1); 55 | } 56 | } 57 | } 58 | } catch (e) { 59 | console.log(`SLOT FETCHER ERROR:`); 60 | console.log(e); 61 | ++attempts; 62 | 63 | // Wait before retrying to avoid hammering the RPC and letting it recover 64 | console.log(`SLOT SUBSCRIPTION TERMINATED ABRUPTLY, SLEEPING FOR ${SLOTS_SUBSCRIPTION_DELAY} BEFORE RETRYING`); 65 | 66 | await sleep(SLOTS_SUBSCRIPTION_DELAY) 67 | } finally { 68 | abortController.abort(); 69 | } 70 | } 71 | }; 72 | --------------------------------------------------------------------------------