Skip to content

Commit

Permalink
solana: Client-side rebroadcast logic (#2153)
Browse files Browse the repository at this point in the history
Only works for posting the VAA currently
  • Loading branch information
kev1n-peters authored and artursapek committed Jun 5, 2024
1 parent f06788d commit b86d450
Show file tree
Hide file tree
Showing 4 changed files with 103 additions and 144 deletions.
19 changes: 14 additions & 5 deletions sdk/src/contexts/solana/utils/computeBudget/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ export async function addComputeBudget(
connection: Connection,
transaction: Transaction,
lockedWritableAccounts: PublicKey[] = [],
feePercentile: number = DEFAULT_FEE_PERCENTILE,
minPriorityFee: number = 0,
feePercentile = DEFAULT_FEE_PERCENTILE,
minPriorityFee = 0,
throwOnSimulateError = false,
): Promise<void> {
if (lockedWritableAccounts.length === 0) {
lockedWritableAccounts = transaction.instructions
Expand All @@ -26,8 +27,9 @@ export async function addComputeBudget(
connection,
transaction,
lockedWritableAccounts,
DEFAULT_FEE_PERCENTILE,
feePercentile,
minPriorityFee,
throwOnSimulateError,
);
transaction.add(...ixs);
}
Expand All @@ -36,19 +38,26 @@ export async function determineComputeBudget(
connection: Connection,
transaction: Transaction,
lockedWritableAccounts: PublicKey[] = [],
feePercentile: number = DEFAULT_FEE_PERCENTILE,
minPriorityFee: number = 0,
feePercentile = DEFAULT_FEE_PERCENTILE,
minPriorityFee = 0,
throwOnSimulateError = false,
): Promise<TransactionInstruction[]> {
let computeBudget = 250_000;
let priorityFee = 1;

try {
// TODO: Use non-deprecated method and pass a commitment level
const simulateResponse = await connection.simulateTransaction(transaction);

if (simulateResponse.value.err) {
console.error(
`Error simulating Solana transaction: ${simulateResponse.value.err}`,
);
if (throwOnSimulateError) {
throw new Error(
`Error simulating Solana transaction: ${simulateResponse.value.err}`,
);
}
}

if (simulateResponse?.value?.unitsConsumed) {
Expand Down
5 changes: 1 addition & 4 deletions sdk/src/contexts/solana/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@ export * from './utils';
/**
* @category Solana
*/
export {
postVaa as postVaaSolana,
postVaaWithRetry as postVaaSolanaWithRetry,
} from './sendAndConfirmPostVaa';
export { postVaaWithRetry as postVaaSolanaWithRetry } from './sendAndConfirmPostVaa';
/**
* @category Solana
*/
Expand Down
68 changes: 6 additions & 62 deletions sdk/src/contexts/solana/utils/sendAndConfirmPostVaa.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import {
Commitment,
ConfirmOptions,
Connection,
Keypair,
PublicKey,
PublicKeyInitData,
Transaction,
} from '@solana/web3.js';
import {
signSendAndConfirmTransaction,
SignTransaction,
sendAndConfirmTransactionsWithRetry,
modifySignTransaction,
Expand Down Expand Up @@ -57,91 +55,37 @@ export async function postVaaWithRetry(
vaa,
commitment,
);
const postVaaTransaction = unsignedTransactions.pop()!;
const postVaaTransaction = unsignedTransactions.pop();
if (!postVaaTransaction) throw new Error('No postVaaTransaction');
postVaaTransaction.feePayer = new PublicKey(payer);

for (const unsignedTransaction of unsignedTransactions) {
unsignedTransaction.feePayer = new PublicKey(payer);
await addComputeBudget(connection, unsignedTransaction);
await addComputeBudget(connection, unsignedTransaction, [], 0.75, 1, true);
}
const responses = await sendAndConfirmTransactionsWithRetry(
connection,
modifySignTransaction(signTransaction, ...signers),
payer.toString(),
unsignedTransactions,
maxRetries,
commitment,
);
//While the signature_set is used to create the final instruction, it doesn't need to sign it.
await addComputeBudget(connection, postVaaTransaction);
await addComputeBudget(connection, postVaaTransaction, [], 0.75, 1, true);
responses.push(
...(await sendAndConfirmTransactionsWithRetry(
connection,
signTransaction,
payer.toString(),
[postVaaTransaction],
maxRetries,
commitment,
)),
);
return responses;
}

/**
* @category Solana
*/
export async function postVaa(
connection: Connection,
signTransaction: SignTransaction,
wormholeProgramId: PublicKeyInitData,
payer: PublicKeyInitData,
vaa: Buffer,
options?: ConfirmOptions,
asyncVerifySignatures: boolean = true,
): Promise<TransactionSignatureAndResponse[]> {
const { unsignedTransactions, signers } =
await createPostSignedVaaTransactions(
connection,
wormholeProgramId,
payer,
vaa,
options?.commitment,
);

const postVaaTransaction = unsignedTransactions.pop()!;

const verifySignatures = async (transaction: Transaction) =>
signSendAndConfirmTransaction(
connection,
payer,
modifySignTransaction(signTransaction, ...signers),
transaction,
options,
);

const output: TransactionSignatureAndResponse[] = [];
if (asyncVerifySignatures) {
const verified = await Promise.all(
unsignedTransactions.map(async (transaction) =>
verifySignatures(transaction),
),
);
output.push(...verified);
} else {
for (const transaction of unsignedTransactions) {
output.push(await verifySignatures(transaction));
}
}
output.push(
await signSendAndConfirmTransaction(
connection,
payer,
signTransaction,
postVaaTransaction,
options,
),
);
return output;
}

/**
* @category Solana
*
Expand Down
155 changes: 82 additions & 73 deletions sdk/src/contexts/solana/utils/utils/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import {
Connection,
PublicKeyInitData,
PublicKey,
ConfirmOptions,
RpcResponseAndContext,
SignatureResult,
TransactionSignature,
Signer,
Commitment,
} from '@solana/web3.js';

/**
Expand Down Expand Up @@ -104,95 +104,31 @@ export class NodeWallet {
}
}

/**
* The transactions provided to this function should be ready to send.
* This function will do the following:
* 1. Add the {@param payer} as the feePayer and latest blockhash to the {@link Transaction}.
* 2. Sign using {@param signTransaction}.
* 3. Send raw transaction.
* 4. Confirm transaction.
*/
export async function signSendAndConfirmTransaction(
connection: Connection,
payer: PublicKeyInitData,
signTransaction: SignTransaction,
unsignedTransaction: Transaction,
options?: ConfirmOptions,
): Promise<TransactionSignatureAndResponse> {
const commitment = options?.commitment;
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash(commitment);
unsignedTransaction.recentBlockhash = blockhash;
unsignedTransaction.feePayer = new PublicKey(payer);

// Sign transaction, broadcast, and confirm
const signed = await signTransaction(unsignedTransaction);
const signature = await connection.sendRawTransaction(
signed.serialize(),
options,
);
const response = await connection.confirmTransaction(
{
blockhash,
lastValidBlockHeight,
signature,
},
commitment,
);
return { signature, response };
}

/**
* @deprecated Please use {@link signSendAndConfirmTransaction} instead, which allows
* retries to be configured in {@link ConfirmOptions}.
*
* The transactions provided to this function should be ready to send.
* This function will do the following:
* 1. Add the {@param payer} as the feePayer and latest blockhash to the {@link Transaction}.
* 2. Sign using {@param signTransaction}.
* 3. Send raw transaction.
* 4. Confirm transaction.
*/
export async function sendAndConfirmTransactionsWithRetry(
connection: Connection,
signTransaction: SignTransaction,
payer: string,
unsignedTransactions: Transaction[],
maxRetries: number = 0,
options?: ConfirmOptions,
maxRetries = 0,
commitment: Commitment = 'finalized',
): Promise<TransactionSignatureAndResponse[]> {
if (unsignedTransactions.length == 0) {
return Promise.reject('No transactions provided to send.');
}

const commitment = options?.commitment;

let currentRetries = 0;
const output: TransactionSignatureAndResponse[] = [];
for (const transaction of unsignedTransactions) {
while (currentRetries <= maxRetries) {
try {
const latest = await connection.getLatestBlockhash(commitment);
transaction.recentBlockhash = latest.blockhash;
transaction.feePayer = new PublicKey(payer);

const signed = await signTransaction(transaction).catch((e) => null);
if (signed === null) {
return Promise.reject('Failed to sign transaction.');
}

const signature = await connection.sendRawTransaction(
signed.serialize(),
options,
);
const response = await connection.confirmTransaction(
{
signature,
...latest,
},
const result = await signSendAndConfirmTransaction(
connection,
signTransaction,
payer,
transaction,
commitment,
);
output.push({ signature, response });
output.push(result);
break;
} catch (e) {
console.error(e);
Expand All @@ -206,3 +142,76 @@ export async function sendAndConfirmTransactionsWithRetry(

return Promise.resolve(output);
}

// This function signs and sends the transaction while constantly checking for confirmation
// and resending the transaction if it hasn't been confirmed after the specified interval
// NOTE: The caller is responsible for simulating the transaction and setting any compute budget
// or priority fee before calling this function
// See https://docs.triton.one/chains/solana/sending-txs for more information
export async function signSendAndConfirmTransaction(
connection: Connection,
signTransaction: SignTransaction,
payer: PublicKeyInitData,
unsignedTransaction: Transaction,
commitment: Commitment = 'finalized',
txRetryInterval = 5000,
): Promise<TransactionSignatureAndResponse> {
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash({
commitment,
});
unsignedTransaction.recentBlockhash = blockhash;
unsignedTransaction.feePayer = new PublicKey(payer);
const tx = await signTransaction(unsignedTransaction);
let confirmTransactionPromise: Promise<
RpcResponseAndContext<SignatureResult>
> | null = null;
let confirmedTx: RpcResponseAndContext<SignatureResult> | null = null;
let txSendAttempts = 1;
let signature = '';
const serializedTx = tx.serialize();
const sendOptions = {
skipPreflight: true,
maxRetries: 0,
preFlightCommitment: commitment, // See PR and linked issue for why setting this matters: https://github.com/anza-xyz/agave/pull/483
};
signature = await connection.sendRawTransaction(serializedTx, sendOptions);
confirmTransactionPromise = connection.confirmTransaction(
{
signature,
blockhash,
lastValidBlockHeight,
},
commitment,
);
// This loop will break once the transaction has been confirmed or the block height is exceeded.
// An exception will be thrown if the block height is exceeded by the confirmTransactionPromise.
// The transaction will be resent if it hasn't been confirmed after the interval.
while (!confirmedTx) {
confirmedTx = await Promise.race([
confirmTransactionPromise,
new Promise<null>((resolve) =>
setTimeout(() => {
resolve(null);
}, txRetryInterval),
),
]);
if (confirmedTx) {
break;
}
console.log(
`Tx not confirmed after ${
txRetryInterval * txSendAttempts++
}ms, resending`,
);
try {
await connection.sendRawTransaction(serializedTx, sendOptions);
} catch (e) {
console.error('Failed to resend transaction:', e);
}
}
return {
signature,
response: confirmedTx,
};
}

0 comments on commit b86d450

Please sign in to comment.