From 660d590ba02e74d06edcd64261bf8acb32dd0e90 Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Thu, 8 Aug 2024 14:55:42 +0000 Subject: [PATCH 01/18] Force all package resolutions to suitable canary release --- .vscode/settings.json | 3 ++ package.json | 68 +++++++++++++++++++++---------------------- 2 files changed, 37 insertions(+), 34 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..12d40dc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.defaultFormatter": "vscode.typescript-language-features" +} \ No newline at end of file diff --git a/package.json b/package.json index a288646..49528e3 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,9 @@ "dependencies": { "@solana-program/compute-budget": "^0.5.0", "@solana-program/system": "^0.5.0", - "@solana/promises": "2.0.0-canary-20241004222707", - "@solana/transaction-confirmation": "2.0.0-canary-20241004222707", - "@solana/web3.js": "2.0.0-canary-20241004222707", + "@solana/promises": "2.0.0-canary-20241023093758", + "@solana/transaction-confirmation": "2.0.0-canary-20241023093758", + "@solana/web3.js": "2.0.0-canary-20241023093758", "axios": "^1.7.3", "bs58": "^6.0.0", "dotenv": "^16.4.5", @@ -22,37 +22,37 @@ "undici": "^6.19.5" }, "overrides": { - "@solana/accounts": "2.0.0-canary-20241004222707", - "@solana/addresses": "2.0.0-canary-20241004222707", - "@solana/assertions": "2.0.0-canary-20241004222707", - "@solana/codecs": "2.0.0-canary-20241004222707", - "@solana/codecs-core": "2.0.0-canary-20241004222707", - "@solana/codecs-data-structures": "2.0.0-canary-20241004222707", - "@solana/codecs-numbers": "2.0.0-canary-20241004222707", - "@solana/codecs-strings": "2.0.0-canary-20241004222707", - "@solana/errors": "2.0.0-canary-20241004222707", - "@solana/fast-stable-stringify": "2.0.0-canary-20241004222707", - "@solana/functional": "2.0.0-canary-20241004222707", - "@solana/instructions": "2.0.0-canary-20241004222707", - "@solana/keys": "2.0.0-canary-20241004222707", - "@solana/programs": "2.0.0-canary-20241004222707", - "@solana/rpc": "2.0.0-canary-20241004222707", - "@solana/rpc-api": "2.0.0-canary-20241004222707", - "@solana/rpc-parsed-types": "2.0.0-canary-20241004222707", - "@solana/rpc-spec": "2.0.0-canary-20241004222707", - "@solana/rpc-spec-types": "2.0.0-canary-20241004222707", - "@solana/rpc-subscriptions": "2.0.0-canary-20241004222707", - "@solana/rpc-subscriptions-api": "2.0.0-canary-20241004222707", - "@solana/rpc-subscriptions-channel-websocket": "2.0.0-canary-20241004222707", - "@solana/rpc-subscriptions-spec": "2.0.0-canary-20241004222707", - "@solana/rpc-subscribable": "2.0.0-canary-20241004222707", - "@solana/rpc-transformers": "2.0.0-canary-20241004222707", - "@solana/rpc-transport-http": "2.0.0-canary-20241004222707", - "@solana/rpc-types": "2.0.0-canary-20241004222707", - "@solana/sysvars": "2.0.0-canary-20241004222707", - "@solana/transaction-confirmation": "2.0.0-canary-20241004222707", - "@solana/transaction-messages": "2.0.0-canary-20241004222707", - "@solana/transactions": "2.0.0-canary-20241004222707" + "@solana/accounts": "2.0.0-canary-20241023093758", + "@solana/addresses": "2.0.0-canary-20241023093758", + "@solana/assertions": "2.0.0-canary-20241023093758", + "@solana/codecs": "2.0.0-canary-20241023093758", + "@solana/codecs-core": "2.0.0-canary-20241023093758", + "@solana/codecs-data-structures": "2.0.0-canary-20241023093758", + "@solana/codecs-numbers": "2.0.0-canary-20241023093758", + "@solana/codecs-strings": "2.0.0-canary-20241023093758", + "@solana/errors": "2.0.0-canary-20241023093758", + "@solana/fast-stable-stringify": "2.0.0-canary-20241023093758", + "@solana/functional": "2.0.0-canary-20241023093758", + "@solana/instructions": "2.0.0-canary-20241023093758", + "@solana/keys": "2.0.0-canary-20241023093758", + "@solana/programs": "2.0.0-canary-20241023093758", + "@solana/rpc": "2.0.0-canary-20241023093758", + "@solana/rpc-api": "2.0.0-canary-20241023093758", + "@solana/rpc-parsed-types": "2.0.0-canary-20241023093758", + "@solana/rpc-spec": "2.0.0-canary-20241023093758", + "@solana/rpc-spec-types": "2.0.0-canary-20241023093758", + "@solana/rpc-subscriptions": "2.0.0-canary-20241023093758", + "@solana/rpc-subscriptions-api": "2.0.0-canary-20241023093758", + "@solana/rpc-subscriptions-channel-websocket": "2.0.0-canary-20241023093758", + "@solana/rpc-subscriptions-spec": "2.0.0-canary-20241023093758", + "@solana/rpc-subscribable": "2.0.0-canary-20241023093758", + "@solana/rpc-transformers": "2.0.0-canary-20241023093758", + "@solana/rpc-transport-http": "2.0.0-canary-20241023093758", + "@solana/rpc-types": "2.0.0-canary-20241023093758", + "@solana/sysvars": "2.0.0-canary-20241023093758", + "@solana/transaction-confirmation": "2.0.0-canary-20241023093758", + "@solana/transaction-messages": "2.0.0-canary-20241023093758", + "@solana/transactions": "2.0.0-canary-20241023093758" }, "devDependencies": { "@types/node": "^20.12.7" From 646d1e6c8377efaab2267c9375c0d28a58f97d0d Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Thu, 8 Aug 2024 16:22:00 +0000 Subject: [PATCH 02/18] Make the RPC global to the application --- ping-thing-client.mjs | 24 ++++++------------------ utils/blockhash.mjs | 5 +++-- utils/rpc.mjs | 8 ++++++++ utils/slot.mjs | 5 ++--- 4 files changed, 19 insertions(+), 23 deletions(-) create mode 100644 utils/rpc.mjs diff --git a/ping-thing-client.mjs b/ping-thing-client.mjs index 19d1971..8fd56be 100644 --- a/ping-thing-client.mjs +++ b/ping-thing-client.mjs @@ -1,5 +1,4 @@ import { - createDefaultRpcTransport, createTransactionMessage, pipe, setTransactionMessageFeePayer, @@ -10,10 +9,8 @@ import { signTransaction, appendTransactionMessageInstructions, sendTransactionWithoutConfirmingFactory, - createSolanaRpcSubscriptions_UNSTABLE, getSignatureFromTransaction, compileTransaction, - createSolanaRpc, SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND // Address, } from "@solana/web3.js"; @@ -24,6 +21,7 @@ import { getTransferSolInstruction } from "@solana-program/system"; import { createRecentSignatureConfirmationPromiseFactory } from "@solana/transaction-confirmation"; import { sleep } from "./utils/misc.mjs"; import { watchBlockhash } from "./utils/blockhash.mjs"; +import { rpc, rpcSubscriptions } from "./utils/rpc.mjs"; import { watchSlotSent } from "./utils/slot.mjs"; import { setMaxListeners } from "events"; import axios from "axios"; @@ -42,10 +40,6 @@ process.on("SIGINT", function () { process.exit(); }); - -const RPC_ENDPOINT = process.env.RPC_ENDPOINT; -const WS_ENDPOINT = process.env.WS_ENDPOINT; - const SLEEP_MS_RPC = process.env.SLEEP_MS_RPC || 2000; const SLEEP_MS_LOOP = process.env.SLEEP_MS_LOOP || 0; const VA_API_KEY = process.env.VA_API_KEY; @@ -56,12 +50,6 @@ const SKIP_VALIDATORS_APP = process.env.SKIP_VALIDATORS_APP || false; if (VERBOSE_LOG) console.log(`Starting script`); -const connection = createSolanaRpc(RPC_ENDPOINT) - -const rpcSubscriptions = createSolanaRpcSubscriptions_UNSTABLE( - WS_ENDPOINT -); - let USER_KEYPAIR; const TX_RETRY_INTERVAL = 2000; @@ -148,12 +136,12 @@ async function pingThing() { console.log(`Sending ${signature}`); const mSendTransaction = sendTransactionWithoutConfirmingFactory({ - rpc: connection, + rpc, }); const getRecentSignatureConfirmationPromise = createRecentSignatureConfirmationPromiseFactory({ - rpc: connection, + rpc, rpcSubscriptions, }); setMaxListeners(100); @@ -220,7 +208,7 @@ async function pingThing() { await sleep(SLEEP_MS_RPC); if (signature !== FAKE_SIGNATURE) { // Capture the slotLanded - let txLanded = await connection + let txLanded = await rpc .getTransaction(signature, { commitment: COMMITMENT_LEVEL, maxSupportedTransactionVersion: 255, @@ -297,7 +285,7 @@ async function pingThing() { } await Promise.all([ - watchBlockhash(gBlockhash, connection), - watchSlotSent(gSlotSent, rpcSubscriptions), + watchBlockhash(gBlockhash), + watchSlotSent(gSlotSent), pingThing(), ]); diff --git a/utils/blockhash.mjs b/utils/blockhash.mjs index 5c88dac..6d522c9 100644 --- a/utils/blockhash.mjs +++ b/utils/blockhash.mjs @@ -1,9 +1,10 @@ import { sleep } from "./misc.mjs"; +import { rpc } from "./rpc.mjs"; const MAX_BLOCKHASH_FETCH_ATTEMPTS = process.env.MAX_BLOCKHASH_FETCH_ATTEMPTS || 5; let attempts = 0; -export const watchBlockhash = async (gBlockhash, connection) => { +export const watchBlockhash = async (gBlockhash) => { // const gBlockhash = { value: null, updated_at: 0 }; while (true) { try { @@ -24,7 +25,7 @@ export const watchBlockhash = async (gBlockhash, connection) => { // fails to respond within 5 seconds, the promise will reject and the // script will log an error. const latestBlockhash = await Promise.race([ - connection.getLatestBlockhash().send(), + rpc.getLatestBlockhash().send(), timeoutPromise, ]); diff --git a/utils/rpc.mjs b/utils/rpc.mjs new file mode 100644 index 0000000..e131a58 --- /dev/null +++ b/utils/rpc.mjs @@ -0,0 +1,8 @@ +import { createSolanaRpc, createSolanaRpcSubscriptions_UNSTABLE } from "@solana/web3.js"; + +export const rpc = createSolanaRpc( + process.env.RPC_ENDPOINT, +); +export const rpcSubscriptions = createSolanaRpcSubscriptions_UNSTABLE( + process.env.WS_ENDPOINT, +); \ No newline at end of file diff --git a/utils/slot.mjs b/utils/slot.mjs index 7da87b2..1a42465 100644 --- a/utils/slot.mjs +++ b/utils/slot.mjs @@ -1,12 +1,11 @@ import { sleep } from "./misc.mjs"; - -import { createSolanaRpcSubscriptions_UNSTABLE } from "@solana/web3.js"; +import { rpcSubscriptions } from "./rpc.mjs"; const MAX_SLOT_FETCH_ATTEMPTS = process.env.MAX_SLOT_FETCH_ATTEMPTS || 100; let attempts = 0; const abortController = new AbortController(); -export const watchSlotSent = async (gSlotSent, rpcSubscriptions) => { +export const watchSlotSent = async (gSlotSent) => { try { const slotNotifications = await rpcSubscriptions .slotsUpdatesNotifications() From bdac4b9005df2e341b317d2ba5edd4e92bbb0016 Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Thu, 8 Aug 2024 16:48:46 +0000 Subject: [PATCH 03/18] Repair missing import of `isSolanaError` --- ping-thing-client.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ping-thing-client.mjs b/ping-thing-client.mjs index 8fd56be..5540e17 100644 --- a/ping-thing-client.mjs +++ b/ping-thing-client.mjs @@ -11,7 +11,8 @@ import { sendTransactionWithoutConfirmingFactory, getSignatureFromTransaction, compileTransaction, - SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND + SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND, + isSolanaError, // Address, } from "@solana/web3.js"; import dotenv from "dotenv"; From 5a7cb9841d6e93b4725ed11e368ba6cb75259ba1 Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Thu, 8 Aug 2024 16:49:30 +0000 Subject: [PATCH 04/18] Implement a blockhash fetcher as an AsyncIterator --- ping-thing-client.mjs | 20 ++----- utils/blockhash.mjs | 132 ++++++++++++++++++++++++++---------------- utils/misc.mjs | 7 ++- 3 files changed, 93 insertions(+), 66 deletions(-) diff --git a/ping-thing-client.mjs b/ping-thing-client.mjs index 5540e17..0ee7982 100644 --- a/ping-thing-client.mjs +++ b/ping-thing-client.mjs @@ -21,7 +21,7 @@ import { getSetComputeUnitLimitInstruction } from "@solana-program/compute-budge import { getTransferSolInstruction } from "@solana-program/system"; import { createRecentSignatureConfirmationPromiseFactory } from "@solana/transaction-confirmation"; import { sleep } from "./utils/misc.mjs"; -import { watchBlockhash } from "./utils/blockhash.mjs"; +import { getLatestBlockhash } from "./utils/blockhash.mjs"; import { rpc, rpcSubscriptions } from "./utils/rpc.mjs"; import { watchSlotSent } from "./utils/slot.mjs"; import { setMaxListeners } from "events"; @@ -54,8 +54,6 @@ if (VERBOSE_LOG) console.log(`Starting script`); let USER_KEYPAIR; const TX_RETRY_INTERVAL = 2000; -const gBlockhash = { value: null, updated_at: 0, lastValidBlockHeight: 0 }; - // Record new slot on `firstShredReceived` const gSlotSent = { value: null, updated_at: 0 }; async function pingThing() { @@ -76,8 +74,6 @@ async function pingThing() { while (true) { await sleep(SLEEP_MS_LOOP); - let blockhash; - let lastValidBlockHeight; let slotSent; let slotLanded; let signature; @@ -86,12 +82,7 @@ async function pingThing() { // Wait fresh data while (true) { - if ( - Date.now() - gBlockhash.updated_at < 10000 && - Date.now() - gSlotSent.updated_at < 50 - ) { - blockhash = gBlockhash.value; - lastValidBlockHeight = gBlockhash.lastValidBlockHeight; + if (Date.now() - gSlotSent.updated_at < 50) { slotSent = gSlotSent.value; break; } @@ -100,15 +91,13 @@ async function pingThing() { } try { try { + const latestBlockhash = await getLatestBlockhash(); const transaction = pipe( createTransactionMessage({ version: 0 }), (tx) => setTransactionMessageFeePayer(feePayer, tx), (tx) => setTransactionMessageLifetimeUsingBlockhash( - { - blockhash: gBlockhash.value, - lastValidBlockHeight: gBlockhash.lastValidBlockHeight, - }, + latestBlockhash, tx ), (tx) => @@ -286,7 +275,6 @@ async function pingThing() { } await Promise.all([ - watchBlockhash(gBlockhash), watchSlotSent(gSlotSent), pingThing(), ]); diff --git a/utils/blockhash.mjs b/utils/blockhash.mjs index 6d522c9..0a8daee 100644 --- a/utils/blockhash.mjs +++ b/utils/blockhash.mjs @@ -1,64 +1,98 @@ -import { sleep } from "./misc.mjs"; -import { rpc } from "./rpc.mjs"; +import { safeRace } from "@solana/promises"; +import { address } from "@solana/web3.js"; -const MAX_BLOCKHASH_FETCH_ATTEMPTS = process.env.MAX_BLOCKHASH_FETCH_ATTEMPTS || 5; -let attempts = 0; +import { timeout } from "./misc.mjs"; +import { rpc, rpcSubscriptions } from "./rpc.mjs"; -export const watchBlockhash = async (gBlockhash) => { - // const gBlockhash = { value: null, updated_at: 0 }; - while (true) { - try { - // Use a 5 second timeout to avoid hanging the script - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => - reject( - new Error( - `${new Date().toISOString()} ERROR: Blockhash fetch operation timed out` - ) - ), - 5000 - ) - ); - // Get the latest blockhash from the RPC node and update the global - // blockhash object with the new value and timestamp. If the RPC node - // fails to respond within 5 seconds, the promise will reject and the - // script will log an error. - const latestBlockhash = await Promise.race([ - rpc.getLatestBlockhash().send(), - timeoutPromise, - ]); +const MAX_BLOCKHASH_FETCH_ATTEMPTS = + process.env.MAX_BLOCKHASH_FETCH_ATTEMPTS || 5; +const RECENT_BLOCKHASHES_ADDRESS = address( + "SysvarRecentB1ockHashes11111111111111111111" +); - gBlockhash.value = latestBlockhash.value.blockhash; - gBlockhash.lastValidBlockHeight = latestBlockhash.value.lastValidBlockHeight; +async function getDifferenceBetweenSlotHeightAndBlockHeight() { + const { absoluteSlot, blockHeight } = await rpc + .getEpochInfo() + .send({ abortSignal: AbortSignal.any([]) }); + return absoluteSlot - blockHeight; +} - gBlockhash.updated_at = Date.now(); - attempts = 0; - } catch (error) { - gBlockhash.value = null; - gBlockhash.updated_at = 0; +function getLatestBlockhashFromNotification( + { + context: { slot }, + value: { + data: { + parsed: { + info: [{ blockhash }], + }, + }, + }, + }, + differenceBetweenSlotHeightAndBlockHeight +) { + return { + blockhash, + lastValidBlockHeight: + slot - differenceBetweenSlotHeightAndBlockHeight + 150n, + }; +} - ++attempts; +let resolveInitialLatestBlockhash; +let latestBlockhashPromise = new Promise((resolve) => { + resolveInitialLatestBlockhash = resolve; +}); - if (error.message.includes("new blockhash")) { - console.log( - `${new Date().toISOString()} ERROR: Unable to obtain a new blockhash` +(async () => { + let attempts = 0; + while (true) { + try { + const [ + differenceBetweenSlotHeightAndBlockHeight, + recentBlockhashesNotifications, + ] = await safeRace([ + Promise.all([ + getDifferenceBetweenSlotHeightAndBlockHeight(), + rpcSubscriptions + .accountNotifications(RECENT_BLOCKHASHES_ADDRESS, { + encoding: "jsonParsed", + }) + .subscribe({ abortSignal: AbortSignal.any([]) }), + ]), + // If the RPC node fails to respond within 5 seconds, throw an error. + timeout(5000), + ]); + // Iterate over the notificatons forever, constantly updating the `latestBlockhash` cache. + for await (const notification of recentBlockhashesNotifications) { + const nextLatestBlockhash = getLatestBlockhashFromNotification( + notification, + differenceBetweenSlotHeightAndBlockHeight + ); + attempts = 0; + if (resolveInitialLatestBlockhash) { + resolveInitialLatestBlockhash(nextLatestBlockhash); + resolveInitialLatestBlockhash = undefined; + } else { + latestBlockhashPromise = Promise.resolve(nextLatestBlockhash); + } + } + } catch (e) { + if (e.message === "Timeout") { + console.error( + `${new Date().toISOString()} ERROR: Blockhash fetch operation timed out` ); } else { - console.log(`${new Date().toISOString()} ERROR: ${error.name}`); - console.log(error.message); - console.log(error); - console.log(JSON.stringify(error)); + console.error(e); } - } finally { - if (attempts >= MAX_BLOCKHASH_FETCH_ATTEMPTS) { - console.log( + if (++attempts >= MAX_BLOCKHASH_FETCH_ATTEMPTS) { + console.error( `${new Date().toISOString()} ERROR: Max attempts for fetching blockhash reached, exiting` ); process.exit(0); } } - - await sleep(5000); } -}; \ No newline at end of file +})(); + +export async function getLatestBlockhash() { + return await latestBlockhashPromise; +} diff --git a/utils/misc.mjs b/utils/misc.mjs index 78fc790..6ca2902 100644 --- a/utils/misc.mjs +++ b/utils/misc.mjs @@ -1,2 +1,7 @@ export const sleep = async (duration) => - await new Promise((resolve) => setTimeout(resolve, duration)); \ No newline at end of file + await new Promise((resolve) => setTimeout(resolve, duration)); + +export const timeout = async (duration) => + await new Promise((_, reject) => setTimeout(() => { + reject(new Error('Timeout')); + }, duration)); \ No newline at end of file From 5c80154bfddfa7a958f6056ad6e27821262f1b06 Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Thu, 8 Aug 2024 17:32:09 +0000 Subject: [PATCH 05/18] Implement a slot fetcher as an AsyncIterator --- ping-thing-client.mjs | 23 +++----------- utils/slot.mjs | 72 +++++++++++++++++++++---------------------- 2 files changed, 40 insertions(+), 55 deletions(-) diff --git a/ping-thing-client.mjs b/ping-thing-client.mjs index 0ee7982..3365476 100644 --- a/ping-thing-client.mjs +++ b/ping-thing-client.mjs @@ -23,7 +23,7 @@ import { createRecentSignatureConfirmationPromiseFactory } from "@solana/transac import { sleep } from "./utils/misc.mjs"; import { getLatestBlockhash } from "./utils/blockhash.mjs"; import { rpc, rpcSubscriptions } from "./utils/rpc.mjs"; -import { watchSlotSent } from "./utils/slot.mjs"; +import { getNextSlot } from "./utils/slot.mjs"; import { setMaxListeners } from "events"; import axios from "axios"; @@ -54,8 +54,6 @@ if (VERBOSE_LOG) console.log(`Starting script`); let USER_KEYPAIR; const TX_RETRY_INTERVAL = 2000; -// Record new slot on `firstShredReceived` -const gSlotSent = { value: null, updated_at: 0 }; async function pingThing() { USER_KEYPAIR = await createKeyPairFromBytes( bs58.decode(process.env.WALLET_PRIVATE_KEYPAIR) @@ -80,15 +78,6 @@ async function pingThing() { let txStart; let txSendAttempts = 1; - // Wait fresh data - while (true) { - if (Date.now() - gSlotSent.updated_at < 50) { - slotSent = gSlotSent.value; - break; - } - - await sleep(1); - } try { try { const latestBlockhash = await getLatestBlockhash(); @@ -135,21 +124,20 @@ async function pingThing() { rpcSubscriptions, }); setMaxListeners(100); - const abortController = new AbortController(); while (true) { try { + slotSent = await getNextSlot(); await mSendTransaction(transactionSignedWithFeePayer, { commitment: "confirmed", maxRetries: 0n, skipPreflight: true, - }); + }) await Promise.race([ getRecentSignatureConfirmationPromise({ signature, commitment: "confirmed", - abortSignal: abortController.signal, }), sleep(TX_RETRY_INTERVAL * txSendAttempts).then(() => { throw new Error("Tx Send Timeout"); @@ -274,7 +262,4 @@ async function pingThing() { } } -await Promise.all([ - watchSlotSent(gSlotSent), - pingThing(), -]); +await pingThing(); diff --git a/utils/slot.mjs b/utils/slot.mjs index 1a42465..731ac3d 100644 --- a/utils/slot.mjs +++ b/utils/slot.mjs @@ -1,45 +1,45 @@ -import { sleep } from "./misc.mjs"; +import { createCipheriv } from "crypto"; import { rpcSubscriptions } from "./rpc.mjs"; const MAX_SLOT_FETCH_ATTEMPTS = process.env.MAX_SLOT_FETCH_ATTEMPTS || 100; -let attempts = 0; -const abortController = new AbortController(); -export const watchSlotSent = async (gSlotSent) => { - try { - const slotNotifications = await rpcSubscriptions - .slotsUpdatesNotifications() - .subscribe({ abortSignal: abortController.signal }); +let resolveSlotPromise; +let nextSlotPromise; - for await (const notification of slotNotifications) { - if (notification.type === "firstShredReceived") { - gSlotSent.value = notification.slot; - gSlotSent.updated_at = Date.now(); - attempts = 0; - continue; - } - - gSlotSent.value = null; - gSlotSent.updated_at = 0; - - ++attempts; - - if (attempts >= MAX_SLOT_FETCH_ATTEMPTS) { - console.log( - `ERROR: Max attempts for fetching slot type "firstShredReceived" reached, exiting` - ); - process.exit(0); - } - - // If update not received in last 3s, re-subscribe - if (gSlotSent.value !== null) { - while (Date.now() - gSlotSent.updated_at < 3000) { - await sleep(1); +(async () => { + let attempts = 0; + while (true) { + try { + const slotNotifications = await rpcSubscriptions + .slotsUpdatesNotifications() + .subscribe({ abortSignal: AbortSignal.any([]) }); + for await (const { slot, type } of slotNotifications) { + if (type === "firstShredReceived") { + attempts = 0; + if (resolveSlotPromise) { + resolveSlotPromise(slot); + } + resolveSlotPromise = undefined; + nextSlotPromise = undefined; + continue; + } + if (++attempts >= MAX_SLOT_FETCH_ATTEMPTS) { + console.log( + `ERROR: Max attempts for fetching slot type "firstShredReceived" reached, exiting` + ); + process.exit(0); } } + } catch (e) { + console.log(`ERROR: ${e}`); + ++attempts; } - } catch (e) { - console.log(`ERROR: ${e}`); - ++attempts; } -}; +})(); + +export async function getNextSlot() { + nextSlotPromise ||= new Promise((resolve) => { + resolveSlotPromise = resolve + }); + return await nextSlotPromise; +} From c3c9aedb9f1397626fc828a27eb3d93a26b3fc4a Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Thu, 8 Aug 2024 17:35:04 +0000 Subject: [PATCH 06/18] Use `TransactionSigner#address` instead of computing the address from `Signer#publicKey` --- ping-thing-client.mjs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ping-thing-client.mjs b/ping-thing-client.mjs index 3365476..dec92d9 100644 --- a/ping-thing-client.mjs +++ b/ping-thing-client.mjs @@ -4,7 +4,6 @@ import { setTransactionMessageFeePayer, setTransactionMessageLifetimeUsingBlockhash, createKeyPairFromBytes, - getAddressFromPublicKey, createSignerFromKeyPair, signTransaction, appendTransactionMessageInstructions, @@ -66,7 +65,6 @@ async function pingThing() { const MAX_TRIES = 3; let tryCount = 0; - const feePayer = await getAddressFromPublicKey(USER_KEYPAIR.publicKey); const signer = await createSignerFromKeyPair(USER_KEYPAIR); while (true) { @@ -83,7 +81,7 @@ async function pingThing() { const latestBlockhash = await getLatestBlockhash(); const transaction = pipe( createTransactionMessage({ version: 0 }), - (tx) => setTransactionMessageFeePayer(feePayer, tx), + (tx) => setTransactionMessageFeePayer(signer.address, tx), (tx) => setTransactionMessageLifetimeUsingBlockhash( latestBlockhash, @@ -97,7 +95,7 @@ async function pingThing() { }), getTransferSolInstruction({ source: signer, - destination: feePayer, + destination: signer.address, amount: 5000, }), ], From 13e34ce89d416c8aa4ecbecb74f82c1f1fc9dcd7 Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Thu, 8 Aug 2024 17:39:05 +0000 Subject: [PATCH 07/18] Only create the base transaction once --- ping-thing-client.mjs | 46 +++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/ping-thing-client.mjs b/ping-thing-client.mjs index dec92d9..72f860b 100644 --- a/ping-thing-client.mjs +++ b/ping-thing-client.mjs @@ -66,7 +66,24 @@ async function pingThing() { let tryCount = 0; const signer = await createSignerFromKeyPair(USER_KEYPAIR); - + const BASE_TRANSACTION_MESSAGE = pipe( + createTransactionMessage({ version: 0 }), + (tx) => setTransactionMessageFeePayer(signer.address, tx), + (tx) => + appendTransactionMessageInstructions( + [ + getSetComputeUnitLimitInstruction({ + units: 500, + }), + getTransferSolInstruction({ + source: signer, + destination: signer.address, + amount: 5000, + }), + ], + tx + ) + ); while (true) { await sleep(SLEEP_MS_LOOP); @@ -79,32 +96,13 @@ async function pingThing() { try { try { const latestBlockhash = await getLatestBlockhash(); - const transaction = pipe( - createTransactionMessage({ version: 0 }), - (tx) => setTransactionMessageFeePayer(signer.address, tx), - (tx) => - setTransactionMessageLifetimeUsingBlockhash( - latestBlockhash, - tx - ), - (tx) => - appendTransactionMessageInstructions( - [ - getSetComputeUnitLimitInstruction({ - units: 500, - }), - getTransferSolInstruction({ - source: signer, - destination: signer.address, - amount: 5000, - }), - ], - tx - ) + const transactionMessage = setTransactionMessageLifetimeUsingBlockhash( + latestBlockhash, + BASE_TRANSACTION_MESSAGE ); const transactionSignedWithFeePayer = await signTransaction( [USER_KEYPAIR], - compileTransaction(transaction) + compileTransaction(transactionMessage) ); signature = getSignatureFromTransaction(transactionSignedWithFeePayer); From a94ee1ce88f571be48923f44d58bb1debf1023af Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Thu, 8 Aug 2024 17:47:20 +0000 Subject: [PATCH 08/18] Use signers API --- package.json | 1 + ping-thing-client.mjs | 15 ++++++--------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 49528e3..a381298 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@solana-program/compute-budget": "^0.5.0", "@solana-program/system": "^0.5.0", "@solana/promises": "2.0.0-canary-20241023093758", + "@solana/signers": "2.0.0-canary-20241023093758", "@solana/transaction-confirmation": "2.0.0-canary-20241023093758", "@solana/web3.js": "2.0.0-canary-20241023093758", "axios": "^1.7.3", diff --git a/ping-thing-client.mjs b/ping-thing-client.mjs index 72f860b..fa52564 100644 --- a/ping-thing-client.mjs +++ b/ping-thing-client.mjs @@ -1,17 +1,16 @@ import { createTransactionMessage, pipe, - setTransactionMessageFeePayer, + setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, createKeyPairFromBytes, createSignerFromKeyPair, - signTransaction, appendTransactionMessageInstructions, sendTransactionWithoutConfirmingFactory, - getSignatureFromTransaction, - compileTransaction, + signTransactionMessageWithSigners, SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND, isSolanaError, + getSignatureFromTransaction, // Address, } from "@solana/web3.js"; import dotenv from "dotenv"; @@ -68,7 +67,7 @@ async function pingThing() { const signer = await createSignerFromKeyPair(USER_KEYPAIR); const BASE_TRANSACTION_MESSAGE = pipe( createTransactionMessage({ version: 0 }), - (tx) => setTransactionMessageFeePayer(signer.address, tx), + (tx) => setTransactionMessageFeePayerSigner(signer, tx), (tx) => appendTransactionMessageInstructions( [ @@ -100,10 +99,8 @@ async function pingThing() { latestBlockhash, BASE_TRANSACTION_MESSAGE ); - const transactionSignedWithFeePayer = await signTransaction( - [USER_KEYPAIR], - compileTransaction(transactionMessage) - ); + const transactionSignedWithFeePayer = + await signTransactionMessageWithSigners(transactionMessage); signature = getSignatureFromTransaction(transactionSignedWithFeePayer); txStart = Date.now(); From 83fe4c03edb8fc58e94af8fff07f06418edbd553 Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Thu, 8 Aug 2024 17:49:08 +0000 Subject: [PATCH 09/18] Hoist factory creation up to global scope --- ping-thing-client.mjs | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/ping-thing-client.mjs b/ping-thing-client.mjs index fa52564..3aa8661 100644 --- a/ping-thing-client.mjs +++ b/ping-thing-client.mjs @@ -52,6 +52,18 @@ if (VERBOSE_LOG) console.log(`Starting script`); let USER_KEYPAIR; const TX_RETRY_INTERVAL = 2000; +setMaxListeners(100); + +const mSendTransaction = sendTransactionWithoutConfirmingFactory({ + rpc, +}); + +const getRecentSignatureConfirmationPromise = + createRecentSignatureConfirmationPromiseFactory({ + rpc, + rpcSubscriptions, + }); + async function pingThing() { USER_KEYPAIR = await createKeyPairFromBytes( bs58.decode(process.env.WALLET_PRIVATE_KEYPAIR) @@ -107,17 +119,6 @@ async function pingThing() { console.log(`Sending ${signature}`); - const mSendTransaction = sendTransactionWithoutConfirmingFactory({ - rpc, - }); - - const getRecentSignatureConfirmationPromise = - createRecentSignatureConfirmationPromiseFactory({ - rpc, - rpcSubscriptions, - }); - setMaxListeners(100); - while (true) { try { slotSent = await getNextSlot(); From 4e627bdbbcaefa8ebf79c05fa6f06ca55ad8e84c Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Thu, 8 Aug 2024 18:00:15 +0000 Subject: [PATCH 10/18] Replace transaction send-and-confirm with default implementation --- ping-thing-client.mjs | 45 +++++++++++++++++-------------------------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/ping-thing-client.mjs b/ping-thing-client.mjs index 3aa8661..c371743 100644 --- a/ping-thing-client.mjs +++ b/ping-thing-client.mjs @@ -1,3 +1,4 @@ +import { safeRace } from "@solana/promises"; import { createTransactionMessage, pipe, @@ -6,19 +7,18 @@ import { createKeyPairFromBytes, createSignerFromKeyPair, appendTransactionMessageInstructions, - sendTransactionWithoutConfirmingFactory, signTransactionMessageWithSigners, SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND, isSolanaError, getSignatureFromTransaction, + sendAndConfirmTransactionFactory, // Address, } from "@solana/web3.js"; import dotenv from "dotenv"; import bs58 from "bs58"; import { getSetComputeUnitLimitInstruction } from "@solana-program/compute-budget"; import { getTransferSolInstruction } from "@solana-program/system"; -import { createRecentSignatureConfirmationPromiseFactory } from "@solana/transaction-confirmation"; -import { sleep } from "./utils/misc.mjs"; +import { sleep, timeout } from "./utils/misc.mjs"; import { getLatestBlockhash } from "./utils/blockhash.mjs"; import { rpc, rpcSubscriptions } from "./utils/rpc.mjs"; import { getNextSlot } from "./utils/slot.mjs"; @@ -54,16 +54,11 @@ const TX_RETRY_INTERVAL = 2000; setMaxListeners(100); -const mSendTransaction = sendTransactionWithoutConfirmingFactory({ +const mSendAndConfirmTransaction = sendAndConfirmTransactionFactory({ rpc, + rpcSubscriptions, }); -const getRecentSignatureConfirmationPromise = - createRecentSignatureConfirmationPromiseFactory({ - rpc, - rpcSubscriptions, - }); - async function pingThing() { USER_KEYPAIR = await createKeyPairFromBytes( bs58.decode(process.env.WALLET_PRIVATE_KEYPAIR) @@ -122,31 +117,27 @@ async function pingThing() { while (true) { try { slotSent = await getNextSlot(); - await mSendTransaction(transactionSignedWithFeePayer, { - commitment: "confirmed", - maxRetries: 0n, - skipPreflight: true, - }) - - await Promise.race([ - getRecentSignatureConfirmationPromise({ - signature, + await safeRace([ + mSendAndConfirmTransaction(transactionSignedWithFeePayer, { commitment: "confirmed", + maxRetries: 0n, + skipPreflight: true, }), - sleep(TX_RETRY_INTERVAL * txSendAttempts).then(() => { - throw new Error("Tx Send Timeout"); - }), + timeout(TX_RETRY_INTERVAL * txSendAttempts), ]); console.log(`Confirmed tx ${signature}`); break; } catch (e) { - console.log( - `Tx not confirmed after ${ - TX_RETRY_INTERVAL * txSendAttempts++ - }ms, resending` - ); + if (e.message === "Timeout") { + console.log( + `Tx not confirmed after ${TX_RETRY_INTERVAL * txSendAttempts++ + }ms, resending` + ); + } else { + throw e; + } } } } catch (e) { From bd1e9065b55aa2526de3d26d76f264d5ce2fb68f Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Sun, 6 Oct 2024 06:36:34 +0000 Subject: [PATCH 11/18] Sprinking dotenv configs where they need to go --- utils/rpc.mjs | 3 +++ utils/slot.mjs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/utils/rpc.mjs b/utils/rpc.mjs index e131a58..6237659 100644 --- a/utils/rpc.mjs +++ b/utils/rpc.mjs @@ -1,5 +1,8 @@ +import dotenv from 'dotenv'; import { createSolanaRpc, createSolanaRpcSubscriptions_UNSTABLE } from "@solana/web3.js"; +dotenv.config(); + export const rpc = createSolanaRpc( process.env.RPC_ENDPOINT, ); diff --git a/utils/slot.mjs b/utils/slot.mjs index 731ac3d..a0a9ffc 100644 --- a/utils/slot.mjs +++ b/utils/slot.mjs @@ -1,6 +1,9 @@ +import dotenv from 'dotenv'; import { createCipheriv } from "crypto"; import { rpcSubscriptions } from "./rpc.mjs"; +dotenv.config(); + const MAX_SLOT_FETCH_ATTEMPTS = process.env.MAX_SLOT_FETCH_ATTEMPTS || 100; let resolveSlotPromise; From e4255218baf7831e44cc4179b1efd14c11e3a1e2 Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Sun, 6 Oct 2024 07:12:52 +0000 Subject: [PATCH 12/18] Move the stopwatch starter _after_ the slot fetcher --- ping-thing-client.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ping-thing-client.mjs b/ping-thing-client.mjs index c371743..43da225 100644 --- a/ping-thing-client.mjs +++ b/ping-thing-client.mjs @@ -110,13 +110,14 @@ async function pingThing() { await signTransactionMessageWithSigners(transactionMessage); signature = getSignatureFromTransaction(transactionSignedWithFeePayer); - txStart = Date.now(); - console.log(`Sending ${signature}`); while (true) { try { slotSent = await getNextSlot(); + + txStart = Date.now(); + await safeRace([ mSendAndConfirmTransaction(transactionSignedWithFeePayer, { commitment: "confirmed", From c5e69b30b3ba56e2fd2ce3cddefa5b0fe6b48376 Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Sun, 6 Oct 2024 21:07:07 +0000 Subject: [PATCH 13/18] JSON.stringify no longer fatals when it encounters a `bigint` --- ping-thing-client.mjs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/ping-thing-client.mjs b/ping-thing-client.mjs index 43da225..9e4a107 100644 --- a/ping-thing-client.mjs +++ b/ping-thing-client.mjs @@ -27,6 +27,10 @@ import axios from "axios"; dotenv.config(); +function safeJSONStringify(val) { + return JSON.stringify(val, (_, val) => typeof val === 'bigint' ? val.toString() : val); +} + const orignalConsoleLog = console.log; console.log = function (...message) { const dateTime = new Date().toUTCString(); @@ -159,7 +163,7 @@ async function pingThing() { console.log(`ERROR: ${e.name}`); console.log(e.message); console.log(e); - console.log(JSON.stringify(e)); + console.log(safeJSONStringify(e)); continue; } @@ -198,7 +202,7 @@ async function pingThing() { } // prepare the payload to send to validators.app - const vAPayload = JSON.stringify({ + const vAPayload = safeJSONStringify({ time: txEnd - txStart, signature, transaction_type: "transfer", @@ -231,9 +235,8 @@ async function pingThing() { if (VERBOSE_LOG) { console.log( - `VA Response ${ - vaResponse.status - } ${JSON.stringify(vaResponse.data)}` + `VA Response ${vaResponse.status + } ${safeJSONStringify(vaResponse.data)}` ); } } From 9d72a3060ed9454d675d3ba75abb461ceed6a97c Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Sun, 6 Oct 2024 21:12:07 +0000 Subject: [PATCH 14/18] Keep the entire list of blockhashes in memory, and vend the latest _unused_ one. --- utils/blockhash.mjs | 48 ++++++++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/utils/blockhash.mjs b/utils/blockhash.mjs index 0a8daee..027d7cb 100644 --- a/utils/blockhash.mjs +++ b/utils/blockhash.mjs @@ -17,31 +17,33 @@ async function getDifferenceBetweenSlotHeightAndBlockHeight() { return absoluteSlot - blockHeight; } -function getLatestBlockhashFromNotification( +function getLatestBlockhashesFromNotification( { context: { slot }, value: { data: { parsed: { - info: [{ blockhash }], + info: blockhashes, }, }, }, }, differenceBetweenSlotHeightAndBlockHeight ) { - return { + return blockhashes.map(({ blockhash }) => ({ blockhash, lastValidBlockHeight: slot - differenceBetweenSlotHeightAndBlockHeight + 150n, - }; + })); } -let resolveInitialLatestBlockhash; -let latestBlockhashPromise = new Promise((resolve) => { - resolveInitialLatestBlockhash = resolve; +let resolveInitialLatestBlockhashes; +let latestBlockhashesPromise = new Promise((resolve) => { + resolveInitialLatestBlockhashes = resolve; }); +let usedBlockhashes = new Set(); + (async () => { let attempts = 0; while (true) { @@ -61,18 +63,25 @@ let latestBlockhashPromise = new Promise((resolve) => { // If the RPC node fails to respond within 5 seconds, throw an error. timeout(5000), ]); - // Iterate over the notificatons forever, constantly updating the `latestBlockhash` cache. + // Iterate over the notificatons forever, constantly updating the `latestBlockhashes` cache. for await (const notification of recentBlockhashesNotifications) { - const nextLatestBlockhash = getLatestBlockhashFromNotification( + const nextLatestBlockhashes = getLatestBlockhashesFromNotification( notification, differenceBetweenSlotHeightAndBlockHeight ); + const nextUsedBlockhashes = new Set(); + for (const { blockhash } of nextLatestBlockhashes) { + if (usedBlockhashes.has(blockhash)) { + nextUsedBlockhashes.add(blockhash) + } + } + usedBlockhashes = nextUsedBlockhashes; attempts = 0; - if (resolveInitialLatestBlockhash) { - resolveInitialLatestBlockhash(nextLatestBlockhash); - resolveInitialLatestBlockhash = undefined; + if (resolveInitialLatestBlockhashes) { + resolveInitialLatestBlockhashes(nextLatestBlockhashes); + resolveInitialLatestBlockhashes = undefined; } else { - latestBlockhashPromise = Promise.resolve(nextLatestBlockhash); + latestBlockhashesPromise = Promise.resolve(nextLatestBlockhashes); } } } catch (e) { @@ -94,5 +103,16 @@ let latestBlockhashPromise = new Promise((resolve) => { })(); export async function getLatestBlockhash() { - return await latestBlockhashPromise; + const latestBlockhashes = await latestBlockhashesPromise; + const latestUnusedBlockhash = latestBlockhashes.find( + ({ blockhash }) => !usedBlockhashes.has(blockhash), + ); + if (!latestUnusedBlockhash) { + console.error( + `${new Date().toISOString()} ERROR: Ran out of unused blockhashes before the subscription could replenish them`, + ); + process.exit(0); + } + usedBlockhashes.add(latestUnusedBlockhash.blockhash); + return latestUnusedBlockhash; } From d00892bc2d45f62fa17e0f9740964baaa4f51dc7 Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Sun, 6 Oct 2024 21:13:24 +0000 Subject: [PATCH 15/18] Start exactly _one_ transaction confirmer, and a separate send retry loop on the side --- ping-thing-client.mjs | 67 ++++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/ping-thing-client.mjs b/ping-thing-client.mjs index 9e4a107..4d006df 100644 --- a/ping-thing-client.mjs +++ b/ping-thing-client.mjs @@ -1,4 +1,3 @@ -import { safeRace } from "@solana/promises"; import { createTransactionMessage, pipe, @@ -11,19 +10,20 @@ import { SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND, isSolanaError, getSignatureFromTransaction, - sendAndConfirmTransactionFactory, + sendTransactionWithoutConfirmingFactory, // Address, } from "@solana/web3.js"; import dotenv from "dotenv"; import bs58 from "bs58"; import { getSetComputeUnitLimitInstruction } from "@solana-program/compute-budget"; import { getTransferSolInstruction } from "@solana-program/system"; -import { sleep, timeout } from "./utils/misc.mjs"; +import { sleep } from "./utils/misc.mjs"; import { getLatestBlockhash } from "./utils/blockhash.mjs"; import { rpc, rpcSubscriptions } from "./utils/rpc.mjs"; import { getNextSlot } from "./utils/slot.mjs"; import { setMaxListeners } from "events"; import axios from "axios"; +import { createRecentSignatureConfirmationPromiseFactory } from "@solana/transaction-confirmation"; dotenv.config(); @@ -58,10 +58,11 @@ const TX_RETRY_INTERVAL = 2000; setMaxListeners(100); -const mSendAndConfirmTransaction = sendAndConfirmTransactionFactory({ +const mConfirmRecentSignature = createRecentSignatureConfirmationPromiseFactory({ rpc, rpcSubscriptions, }); +const mSendTransactionWithoutConfirming = sendTransactionWithoutConfirmingFactory({ rpc }); async function pingThing() { USER_KEYPAIR = await createKeyPairFromBytes( @@ -104,6 +105,7 @@ async function pingThing() { let txSendAttempts = 1; try { + const pingAbortController = new AbortController(); try { const latestBlockhash = await getLatestBlockhash(); const transactionMessage = setTransactionMessageLifetimeUsingBlockhash( @@ -116,35 +118,40 @@ async function pingThing() { console.log(`Sending ${signature}`); - while (true) { - try { - slotSent = await getNextSlot(); - - txStart = Date.now(); - - await safeRace([ - mSendAndConfirmTransaction(transactionSignedWithFeePayer, { - commitment: "confirmed", - maxRetries: 0n, - skipPreflight: true, - }), - timeout(TX_RETRY_INTERVAL * txSendAttempts), - ]); - - console.log(`Confirmed tx ${signature}`); - - break; - } catch (e) { - if (e.message === "Timeout") { - console.log( - `Tx not confirmed after ${TX_RETRY_INTERVAL * txSendAttempts++ - }ms, resending` - ); + let sendAbortController; + function sendTransaction() { + sendAbortController = new AbortController() + mSendTransactionWithoutConfirming(transactionSignedWithFeePayer, { + abortSignal: sendAbortController.signal, + commitment: COMMITMENT_LEVEL, + maxRetries: 0n, + skipPreflight: true, + }).catch(e => { + if (e instanceof Error && e.name === 'AbortError') { + return; } else { throw e; } - } + }); } + slotSent = await getNextSlot(); + const sendRetryInterval = setInterval(() => { + sendAbortController.abort(); + console.log(`Tx not confirmed after ${TX_RETRY_INTERVAL * txSendAttempts++}ms, resending`); + sendTransaction(); + }, TX_RETRY_INTERVAL); + pingAbortController.signal.addEventListener('abort', () => { + clearInterval(sendRetryInterval); + sendAbortController.abort(); + }); + txStart = Date.now(); + sendTransaction(); + await mConfirmRecentSignature({ + abortSignal: pingAbortController.signal, + commitment: COMMITMENT_LEVEL, + signature, + }); + console.log(`Confirmed tx ${signature}`); } catch (e) { // Log and loop if we get a bad blockhash. if (isSolanaError(e, SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND)) { @@ -169,6 +176,8 @@ async function pingThing() { // Need to submit a fake signature to pass the import filters signature = FAKE_SIGNATURE; + } finally { + pingAbortController.abort(); } const txEnd = Date.now(); From f98870b1d265d7fd27ae2e7c55921f5223d5fd9b Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Tue, 8 Oct 2024 14:29:46 +0000 Subject: [PATCH 16/18] Fetch the next slot by watching the trailing edge (completed) instead of the leading edge (firstShredReceived) --- utils/slot.mjs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/utils/slot.mjs b/utils/slot.mjs index a0a9ffc..0eca7f0 100644 --- a/utils/slot.mjs +++ b/utils/slot.mjs @@ -17,10 +17,19 @@ let nextSlotPromise; .slotsUpdatesNotifications() .subscribe({ abortSignal: AbortSignal.any([]) }); for await (const { slot, type } of slotNotifications) { - if (type === "firstShredReceived") { + let nextSlot; + switch (type) { + case 'completed': + nextSlot = slot + 1n; + break; + case 'firstShredReceived': + nextSlot = slot; + break + } + if (nextSlot != null) { attempts = 0; if (resolveSlotPromise) { - resolveSlotPromise(slot); + resolveSlotPromise(nextSlot); } resolveSlotPromise = undefined; nextSlotPromise = undefined; @@ -28,7 +37,7 @@ let nextSlotPromise; } if (++attempts >= MAX_SLOT_FETCH_ATTEMPTS) { console.log( - `ERROR: Max attempts for fetching slot type "firstShredReceived" reached, exiting` + `ERROR: Max attempts for fetching slot type "completed" or "firstShredReceived" reached, exiting` ); process.exit(0); } From e8d53b478d57a467c0cd77eafa3428f08d660e22 Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Thu, 10 Oct 2024 18:17:49 +0000 Subject: [PATCH 17/18] Prevent uncaught errors in the send-retry loop from bubbling up uncaught --- ping-thing-client.mjs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/ping-thing-client.mjs b/ping-thing-client.mjs index 4d006df..d774d28 100644 --- a/ping-thing-client.mjs +++ b/ping-thing-client.mjs @@ -24,6 +24,7 @@ import { getNextSlot } from "./utils/slot.mjs"; import { setMaxListeners } from "events"; import axios from "axios"; import { createRecentSignatureConfirmationPromiseFactory } from "@solana/transaction-confirmation"; +import { safeRace } from '@solana/promises'; dotenv.config(); @@ -118,9 +119,13 @@ async function pingThing() { console.log(`Sending ${signature}`); + let rejectSendLoop; + const sendLoopPromise = new Promise((_, reject) => { + rejectSendLoop = reject; + }); let sendAbortController; function sendTransaction() { - sendAbortController = new AbortController() + sendAbortController = new AbortController(); mSendTransactionWithoutConfirming(transactionSignedWithFeePayer, { abortSignal: sendAbortController.signal, commitment: COMMITMENT_LEVEL, @@ -130,7 +135,7 @@ async function pingThing() { if (e instanceof Error && e.name === 'AbortError') { return; } else { - throw e; + rejectSendLoop(e); } }); } @@ -146,11 +151,14 @@ async function pingThing() { }); txStart = Date.now(); sendTransaction(); - await mConfirmRecentSignature({ - abortSignal: pingAbortController.signal, - commitment: COMMITMENT_LEVEL, - signature, - }); + await safeRace([ + mConfirmRecentSignature({ + abortSignal: pingAbortController.signal, + commitment: COMMITMENT_LEVEL, + signature, + }), + sendLoopPromise, + ]); console.log(`Confirmed tx ${signature}`); } catch (e) { // Log and loop if we get a bad blockhash. From 4acf40b4516cb63bf50a999a7b5ca18c5e0baeae Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Tue, 15 Oct 2024 17:44:11 +0000 Subject: [PATCH 18/18] Fail the transaction loop when the blockhash expires --- ping-thing-client.mjs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/ping-thing-client.mjs b/ping-thing-client.mjs index d774d28..4334e50 100644 --- a/ping-thing-client.mjs +++ b/ping-thing-client.mjs @@ -11,6 +11,7 @@ import { isSolanaError, getSignatureFromTransaction, sendTransactionWithoutConfirmingFactory, + SOLANA_ERROR__BLOCK_HEIGHT_EXCEEDED, // Address, } from "@solana/web3.js"; import dotenv from "dotenv"; @@ -23,7 +24,7 @@ import { rpc, rpcSubscriptions } from "./utils/rpc.mjs"; import { getNextSlot } from "./utils/slot.mjs"; import { setMaxListeners } from "events"; import axios from "axios"; -import { createRecentSignatureConfirmationPromiseFactory } from "@solana/transaction-confirmation"; +import { createBlockHeightExceedencePromiseFactory, createRecentSignatureConfirmationPromiseFactory } from "@solana/transaction-confirmation"; import { safeRace } from '@solana/promises'; dotenv.config(); @@ -63,6 +64,10 @@ const mConfirmRecentSignature = createRecentSignatureConfirmationPromiseFactory( rpc, rpcSubscriptions, }); +const mThrowOnBlockheightExceedence = createBlockHeightExceedencePromiseFactory({ + rpc, + rpcSubscriptions, +}); const mSendTransactionWithoutConfirming = sendTransactionWithoutConfirmingFactory({ rpc }); async function pingThing() { @@ -157,6 +162,11 @@ async function pingThing() { commitment: COMMITMENT_LEVEL, signature, }), + mThrowOnBlockheightExceedence({ + abortSignal: pingAbortController.signal, + commitment: COMMITMENT_LEVEL, + lastValidBlockHeight: latestBlockhash.lastValidBlockHeight, + }), sendLoopPromise, ]); console.log(`Confirmed tx ${signature}`); @@ -170,7 +180,7 @@ async function pingThing() { // If the transaction expired on the chain. Make a log entry and send // to VA. Otherwise log and loop. - if (e.name === "TransactionExpiredBlockheightExceededError") { + if (isSolanaError(e, SOLANA_ERROR__BLOCK_HEIGHT_EXCEEDED)) { console.log( `ERROR: Blockhash expired/block height exceeded. TX failure sent to VA.` );