@hyperlane-xyz/sdk
Version:
The official SDK for the Hyperlane Network
206 lines • 8.79 kB
JavaScript
import { ComputeBudgetProgram, Keypair, Transaction, } from '@solana/web3.js';
import { rootLogger, sleep } from '@hyperlane-xyz/utils';
import { SEALEVEL_PRIORITY_FEES } from '../../consts/sealevel.js';
/**
* Keypair-based SVM transaction signer
*/
export class KeypairSvmTransactionSigner {
publicKey;
keypair;
constructor(privateKey) {
this.keypair = Keypair.fromSecretKey(privateKey);
this.publicKey = this.keypair.publicKey;
}
async signTransaction(transaction) {
transaction.partialSign(this.keypair);
return transaction;
}
}
export class SvmMultiProtocolSignerAdapter {
chainName;
signer;
svmProvider;
config;
logger = rootLogger.child({
module: 'SvmMultiProtocolSignerAdapter',
});
constructor(chainName, signer, multiProtocolProvider, config) {
this.chainName = chainName;
this.signer = signer;
this.svmProvider = multiProtocolProvider.getSolanaWeb3Provider(chainName);
this.config = {
maxConfirmationAttempts: config?.maxConfirmationAttempts ?? 30,
pollingDelayMs: config?.pollingDelayMs ?? 1000,
commitment: config?.commitment ?? 'confirmed',
enableBlockhashResubmit: config?.enableBlockhashResubmit ?? true,
retryOnRpcErrors: config?.retryOnRpcErrors ?? true,
};
}
publicKey() {
return this.signer.publicKey;
}
async address() {
return this.signer.publicKey.toBase58();
}
/**
* Build and send a transaction from raw instructions
*/
async buildAndSendTransaction(instructions, options) {
const tx = this.buildTransaction(instructions, options);
return this.signAndConfirm(tx);
}
/**
* Send and confirm a pre-built transaction (IMultiProtocolSigner interface)
*/
async sendAndConfirmTransaction(tx, _options) {
return this.signAndConfirm(tx.transaction, tx.extraSigners);
}
// ============ Private Methods ============
/**
* Build transaction from instructions with optional priority fees
*/
buildTransaction(instructions, options) {
const tx = new Transaction();
// Add priority fee if enabled and not already present
const includePriorityFee = options?.includePriorityFee ?? true;
if (includePriorityFee) {
const hasPriorityFeeIx = instructions.some((ix) => ix.programId.equals(ComputeBudgetProgram.programId));
if (!hasPriorityFeeIx) {
const priorityFee = options?.priorityFee ?? SEALEVEL_PRIORITY_FEES[this.chainName];
if (priorityFee) {
tx.add(ComputeBudgetProgram.setComputeUnitPrice({
microLamports: priorityFee,
}));
}
}
}
// Add all instructions
instructions.forEach((ix) => tx.add(ix));
tx.feePayer = this.signer.publicKey;
return tx;
}
/**
* Sign and confirm transaction with blockhash resubmit on expiry
*/
async signAndConfirm(transaction, extraSigners) {
// Get initial blockhash
const { blockhash, lastValidBlockHeight } = await this.svmProvider.getLatestBlockhash(this.config.commitment);
transaction.recentBlockhash = blockhash;
if (!transaction.feePayer) {
transaction.feePayer = this.signer.publicKey;
}
// Sign with extra signers first (e.g., randomWallet for Sealevel transferRemote).
// Uses partialSign to avoid clearing any existing signatures.
// This re-signs with the fresh blockhash, overwriting the adapter's pre-sign.
for (const signer of extraSigners ?? []) {
transaction.partialSign(signer);
}
// Sign with main signer (uses partialSign via KeypairSvmTransactionSigner)
const signedTx = await this.signer.signTransaction(transaction);
// Send initial transaction
const signature = await this.sendRawTransaction(signedTx);
// Poll for confirmation with optional resubmit
const result = await this.pollForConfirmation(signature, signedTx, lastValidBlockHeight, extraSigners);
return result;
}
/**
* Poll for transaction confirmation with blockhash expiry handling
*/
async pollForConfirmation(initialSignature, transaction, lastValidBlockHeight, extraSigners) {
let signature = initialSignature;
let attempts = 0;
let currentLastValidBlockHeight = lastValidBlockHeight;
while (attempts < this.config.maxConfirmationAttempts) {
await sleep(this.config.pollingDelayMs);
attempts++;
try {
// Check and handle blockhash expiry
const resubmitResult = await this.checkAndResubmitIfExpired(signature, transaction, currentLastValidBlockHeight, extraSigners);
if (resubmitResult) {
signature = resubmitResult.signature;
currentLastValidBlockHeight = resubmitResult.lastValidBlockHeight;
continue;
}
// Check if transaction is confirmed
const isConfirmed = await this.checkTransactionConfirmation(signature);
if (isConfirmed) {
this.logger.info(`Transaction ${signature} confirmed after ${attempts} attempts`);
return signature;
}
}
catch (error) {
// If it's a transaction failure error, rethrow immediately
if (error instanceof Error &&
error.message.includes('Transaction failed')) {
throw error;
}
// Handle RPC errors based on config
if (!this.config.retryOnRpcErrors) {
throw error;
}
// Log RPC errors but continue polling (temporary issues)
this.logger.warn(`Polling attempt ${attempts} failed: ${error}`);
}
}
throw new Error(`Transaction confirmation timeout after ${this.config.maxConfirmationAttempts} attempts`);
}
/**
* Check if blockhash expired and resubmit transaction if needed
* Returns new signature and lastValidBlockHeight if resubmitted, null otherwise
*/
async checkAndResubmitIfExpired(signature, transaction, lastValidBlockHeight, extraSigners) {
if (!this.config.enableBlockhashResubmit) {
return null;
}
const currentBlockHeight = await this.svmProvider.getBlockHeight();
if (currentBlockHeight <= lastValidBlockHeight) {
return null; // Blockhash still valid
}
this.logger.warn(`Blockhash expired at block ${lastValidBlockHeight}, current ${currentBlockHeight}. Resubmitting...`);
// Get fresh blockhash and resubmit
const { blockhash, lastValidBlockHeight: newLastValid } = await this.svmProvider.getLatestBlockhash(this.config.commitment);
transaction.recentBlockhash = blockhash;
// Re-sign extra signers with new blockhash
for (const signer of extraSigners ?? []) {
transaction.partialSign(signer);
}
const signedTx = await this.signer.signTransaction(transaction);
const newSignature = await this.sendRawTransaction(signedTx);
this.logger.info(`Resubmitted with signature: ${newSignature}`);
return {
signature: newSignature,
lastValidBlockHeight: newLastValid,
};
}
/**
* Check if transaction is confirmed
* Returns true if confirmed, false if pending
* Throws if transaction failed
*/
async checkTransactionConfirmation(signature) {
const status = await this.svmProvider.getSignatureStatus(signature, {
searchTransactionHistory: true,
});
if (!status.value) {
return false; // Transaction not yet seen
}
// Check for transaction error
if (status.value.err) {
throw new Error(`Transaction failed: ${JSON.stringify(status.value.err)}`);
}
// Check if confirmed at required commitment level
const confirmationStatus = status.value.confirmationStatus;
return (confirmationStatus === this.config.commitment ||
confirmationStatus === 'finalized');
}
/**
* Send signed transaction to network
*/
async sendRawTransaction(transaction) {
return await this.svmProvider.sendRawTransaction(transaction.serialize(), {
skipPreflight: false,
maxRetries: 3,
});
}
}
//# sourceMappingURL=solana-web3js.js.map