From d2c7d592c5225a9ae3edb5155a143c02d198fd01 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 9 Sep 2026 15:45:57 +0200 Subject: [PATCH] refactor(scripts): extract transaction send helpers into transactions.ts --- scripts/utils/daoActions.ts | 159 +----------------------------- scripts/utils/futarchyProposal.ts | 8 +- scripts/utils/squads.ts | 2 +- scripts/utils/transactions.ts | 159 ++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 165 deletions(-) create mode 100644 scripts/utils/transactions.ts diff --git a/scripts/utils/daoActions.ts b/scripts/utils/daoActions.ts index b3ce9b16..193bcf4a 100644 --- a/scripts/utils/daoActions.ts +++ b/scripts/utils/daoActions.ts @@ -1,11 +1,9 @@ import { AnchorProvider } from "@coral-xyz/anchor"; import * as multisig from "@sqds/multisig"; import BN from "bn.js"; -import bs58 from "bs58"; import { Keypair, PublicKey, - SendTransactionError, Transaction, TransactionInstruction, } from "@solana/web3.js"; @@ -37,6 +35,7 @@ import { } from "@metadaoproject/programs/futarchy/v0.6"; import { buildAdminApprovalTransactions } from "./adminApproval.js"; import { getSquadsPdasFromDao, probeSquadsVaultTransaction } from "./squads.js"; +import { sendAndConfirm, sendWithRetries } from "./transactions.js"; const SEED_AMM_POSITION = Buffer.from("amm_position"); const SEED_POSITION_NFT_MINT = Buffer.from("position_nft_mint"); @@ -597,162 +596,6 @@ export const buildDaoActionTransactions = async ({ }; }; -// Sends a signed transaction, throwing if it isn't confirmed or lands with an -// error -export const sendAndConfirm = async ( - provider: AnchorProvider, - transaction: Transaction, -) => { - const signature = await provider.connection.sendRawTransaction( - transaction.serialize(), - ); - const status = await provider.connection.confirmTransaction( - signature, - "confirmed", - ); - if (status.value.err) { - throw new Error( - `Transaction ${signature} failed: ${JSON.stringify(status.value.err)}`, - ); - } - return signature; -}; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -// A preflight rejection: the node simulated the transaction and refused to -// forward it. Any other send error may have come back after the transaction -// was forwarded. -const isPreflightRejection = (error: unknown) => - error instanceof SendTransactionError && - error.message.includes("Transaction simulation failed"); - -/** - * What a probe found at the accounts a transaction creates: they exist - * holding what the transaction puts there (`landed`), don't exist (`absent`), - * or exist holding something else, like another proposal at the same squads - * transaction index (`taken`). - */ -export type ProbeResult = "landed" | "absent" | "taken"; - -/** - * Sends a transaction, retrying until it's confirmed at the confirmed - * commitment or `attempts` run out, without a retry ever duplicating what an - * earlier attempt created: - * - * - A preflight rejection was never broadcast. Any other send error is - * followed by confirming the signature until it lands or its blockhash - * expires, after which it can't land anymore. - * - Before each attempt, `probe` looks at the created accounts: an attempt - * that landed without being confirmed is adopted, and when another - * transaction took the address the transaction is rebuilt via `build`. - * Otherwise `build` is called once, so an address it derives from mutable - * state (a squads transaction index) stays pinned across attempts. - * - * Returns what `build` returned plus the confirmed signature - null when the - * probe found the accounts before anything was sent. - */ -export const sendWithRetries = async ({ - provider, - payer, - signers = [], - name, - build, - probe, - attempts = 5, -}: { - provider: AnchorProvider; - payer: Keypair; - signers?: Keypair[]; - name: string; - build: () => Promise; - probe: (built: T) => Promise; - attempts?: number; -}): Promise => { - let built = await build(); - let lastSignature: string | null = null; - let lastError: unknown; - - const landed = () => { - if (lastSignature) { - console.log(`${name} landed!`); - console.log("Transaction signature:", lastSignature); - } else { - console.log(`${name} already exists - skipping`); - } - return { ...built, signature: lastSignature }; - }; - - for (let attempt = 1; attempt <= attempts; attempt++) { - const state = await probe(built); - if (state === "landed") { - return landed(); - } - if (state === "taken") { - console.warn(`${name}: address taken by another transaction, rebuilding`); - built = await build(); - } - - try { - const { transaction } = built; - const { blockhash, lastValidBlockHeight } = - await provider.connection.getLatestBlockhash("confirmed"); - transaction.recentBlockhash = blockhash; - transaction.feePayer = payer.publicKey; - transaction.sign(payer, ...signers); - const signature = bs58.encode(transaction.signature!); - - try { - await provider.connection.sendRawTransaction(transaction.serialize(), { - preflightCommitment: "confirmed", - }); - } catch (error) { - if (isPreflightRejection(error)) { - throw error; - } - // May have been forwarded before the error came back - confirm it - // like a sent one - } - lastSignature = signature; - - const status = await provider.connection.confirmTransaction( - { signature, blockhash, lastValidBlockHeight }, - "confirmed", - ); - if (status.value.err) { - throw new Error( - `Transaction ${signature} failed: ${JSON.stringify(status.value.err)}`, - ); - } - - console.log(`${name} created!`); - console.log("Transaction signature:", signature); - return { ...built, signature }; - } catch (error) { - lastError = error; - console.warn( - `${name}: attempt ${attempt} of ${attempts} failed -`, - error instanceof Error ? error.message : error, - ); - if (attempt < attempts) { - await sleep(2_000); - } - } - } - - // Out of attempts - a last look so the failure is reported truthfully - const state = await probe(built); - if (state === "landed") { - return landed(); - } - console.error( - state === "taken" - ? `${name}: giving up - another transaction took the address, nothing of this run's landed` - : `${name}: giving up - nothing landed`, - ); - throw lastError; -}; - /** * Signs and sends the transactions built by buildDaoActionTransactions in * order (setup if any, DAO multisig, ops multisig), logging the created diff --git a/scripts/utils/futarchyProposal.ts b/scripts/utils/futarchyProposal.ts index 13423de8..974a93ec 100644 --- a/scripts/utils/futarchyProposal.ts +++ b/scripts/utils/futarchyProposal.ts @@ -15,18 +15,14 @@ import { FutarchyClient, getProposalAddr, } from "@metadaoproject/programs/futarchy/v0.6"; -import { - buildDaoActions, - DaoActionBuilder, - sendAndConfirm, - sendWithRetries, -} from "./daoActions.js"; +import { buildDaoActions, DaoActionBuilder } from "./daoActions.js"; import { compareVaultTransactionInstructions, createSquadsVaultTxAndProposal, getSquadsPdasFromDao, probeSquadsVaultTransaction, } from "./squads.js"; +import { sendAndConfirm, sendWithRetries } from "./transactions.js"; const accountExists = async (connection: Connection, account: PublicKey) => (await connection.getAccountInfo(account, "confirmed")) !== null; diff --git a/scripts/utils/squads.ts b/scripts/utils/squads.ts index ba7d75bf..4556d393 100644 --- a/scripts/utils/squads.ts +++ b/scripts/utils/squads.ts @@ -6,7 +6,7 @@ import { TransactionMessage, } from "@solana/web3.js"; import * as multisig from "@sqds/multisig"; -import type { ProbeResult } from "./daoActions.js"; +import type { ProbeResult } from "./transactions.js"; // Returns the multisig, spending limit and 0th vault pda for a given dao address export const getSquadsPdasFromDao = async ( diff --git a/scripts/utils/transactions.ts b/scripts/utils/transactions.ts new file mode 100644 index 00000000..6e8ade83 --- /dev/null +++ b/scripts/utils/transactions.ts @@ -0,0 +1,159 @@ +import { AnchorProvider } from "@coral-xyz/anchor"; +import bs58 from "bs58"; +import { Keypair, SendTransactionError, Transaction } from "@solana/web3.js"; + +/** + * What a probe found at the accounts a transaction creates: they exist + * holding what the transaction puts there (`landed`), don't exist (`absent`), + * or exist holding something else, like another proposal at the same squads + * transaction index (`taken`). + */ +export type ProbeResult = "landed" | "absent" | "taken"; + +// Sends a signed transaction, throwing if it isn't confirmed or lands with an +// error +export const sendAndConfirm = async ( + provider: AnchorProvider, + transaction: Transaction, +) => { + const signature = await provider.connection.sendRawTransaction( + transaction.serialize(), + ); + const status = await provider.connection.confirmTransaction( + signature, + "confirmed", + ); + if (status.value.err) { + throw new Error( + `Transaction ${signature} failed: ${JSON.stringify(status.value.err)}`, + ); + } + return signature; +}; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// A preflight rejection: the node simulated the transaction and refused to +// forward it. Any other send error may have come back after the transaction +// was forwarded. +const isPreflightRejection = (error: unknown) => + error instanceof SendTransactionError && + error.message.includes("Transaction simulation failed"); + +/** + * Sends a transaction, retrying until it's confirmed at the confirmed + * commitment or `attempts` run out, without a retry ever duplicating what an + * earlier attempt created: + * + * - A preflight rejection was never broadcast. Any other send error is + * followed by confirming the signature until it lands or its blockhash + * expires, after which it can't land anymore. + * - Before each attempt, `probe` looks at the created accounts: an attempt + * that landed without being confirmed is adopted, and when another + * transaction took the address the transaction is rebuilt via `build`. + * Otherwise `build` is called once, so an address it derives from mutable + * state (a squads transaction index) stays pinned across attempts. + * + * Returns what `build` returned plus the confirmed signature - null when the + * probe found the accounts before anything was sent. + */ +export const sendWithRetries = async ({ + provider, + payer, + signers = [], + name, + build, + probe, + attempts = 5, +}: { + provider: AnchorProvider; + payer: Keypair; + signers?: Keypair[]; + name: string; + build: () => Promise; + probe: (built: T) => Promise; + attempts?: number; +}): Promise => { + let built = await build(); + let lastSignature: string | null = null; + let lastError: unknown; + + const landed = () => { + if (lastSignature) { + console.log(`${name} landed!`); + console.log("Transaction signature:", lastSignature); + } else { + console.log(`${name} already exists - skipping`); + } + return { ...built, signature: lastSignature }; + }; + + for (let attempt = 1; attempt <= attempts; attempt++) { + const state = await probe(built); + if (state === "landed") { + return landed(); + } + if (state === "taken") { + console.warn(`${name}: address taken by another transaction, rebuilding`); + built = await build(); + } + + try { + const { transaction } = built; + const { blockhash, lastValidBlockHeight } = + await provider.connection.getLatestBlockhash("confirmed"); + transaction.recentBlockhash = blockhash; + transaction.feePayer = payer.publicKey; + transaction.sign(payer, ...signers); + const signature = bs58.encode(transaction.signature!); + + try { + await provider.connection.sendRawTransaction(transaction.serialize(), { + preflightCommitment: "confirmed", + }); + } catch (error) { + if (isPreflightRejection(error)) { + throw error; + } + // May have been forwarded before the error came back - confirm it + // like a sent one + } + lastSignature = signature; + + const status = await provider.connection.confirmTransaction( + { signature, blockhash, lastValidBlockHeight }, + "confirmed", + ); + if (status.value.err) { + throw new Error( + `Transaction ${signature} failed: ${JSON.stringify(status.value.err)}`, + ); + } + + console.log(`${name} created!`); + console.log("Transaction signature:", signature); + return { ...built, signature }; + } catch (error) { + lastError = error; + console.warn( + `${name}: attempt ${attempt} of ${attempts} failed -`, + error instanceof Error ? error.message : error, + ); + if (attempt < attempts) { + await sleep(2_000); + } + } + } + + // Out of attempts - a last look so the failure is reported truthfully + const state = await probe(built); + if (state === "landed") { + return landed(); + } + console.error( + state === "taken" + ? `${name}: giving up - another transaction took the address, nothing of this run's landed` + : `${name}: giving up - nothing landed`, + ); + throw lastError; +};