diff --git a/examples/src/helpers/helpers.ts b/examples/src/helpers/helpers.ts index 133e5e9ba..81d7ed7a8 100644 --- a/examples/src/helpers/helpers.ts +++ b/examples/src/helpers/helpers.ts @@ -18,7 +18,7 @@ import { import { getAlgorandSigner } from "@wormhole-foundation/connect-sdk-algorand/src/testing"; import { getCosmwasmSigner } from "@wormhole-foundation/connect-sdk-cosmwasm/src/testing"; import { getEvmSigner } from "@wormhole-foundation/connect-sdk-evm/src/testing"; -import { getSolanaSigner } from "@wormhole-foundation/connect-sdk-solana/src/testing"; +import { getSolanaSignAndSendSigner } from "@wormhole-foundation/connect-sdk-solana/src/testing"; // Use .env.example as a template for your .env file and populate it with secrets // for funded accounts on the relevant chain+network combos to run the example @@ -56,7 +56,7 @@ export async function getStuff< const platform = chain.platform.utils()._platform; switch (platform) { case "Solana": - signer = await getSolanaSigner(await chain.getRpc(), getEnv("SOL_PRIVATE_KEY")); + signer = await getSolanaSignAndSendSigner(await chain.getRpc(), getEnv("SOL_PRIVATE_KEY")); break; case "Cosmwasm": signer = await getCosmwasmSigner(await chain.getRpc(), getEnv("COSMOS_MNEMONIC")); diff --git a/platforms/solana/__tests__/integration/tokenBridge.test.ts b/platforms/solana/__tests__/integration/tokenBridge.test.ts index 2a6091a12..bc378ea34 100644 --- a/platforms/solana/__tests__/integration/tokenBridge.test.ts +++ b/platforms/solana/__tests__/integration/tokenBridge.test.ts @@ -204,7 +204,7 @@ describe('TokenBridge Tests', () => { expect(attestTx.chain).toEqual(chain); const { transaction } = attestTx; - expect(transaction.instructions).toHaveLength(2); + expect(transaction.transaction.instructions).toHaveLength(2); }); test('Submit Attestation', async () => { @@ -229,7 +229,7 @@ describe('TokenBridge Tests', () => { }); const submitAttestation = tb.submitAttestation(vaa, sender); - const allTxns = []; + const allTxns: SolanaUnsignedTransaction[] = []; for await (const atx of submitAttestation) { allTxns.push(atx); } @@ -237,9 +237,9 @@ describe('TokenBridge Tests', () => { const [verifySig, postVaa, create] = allTxns; // - expect(verifySig.transaction.instructions).toHaveLength(2); - expect(postVaa.transaction.instructions).toHaveLength(1); - expect(create.transaction.instructions).toHaveLength(1); + expect(verifySig.transaction.transaction.instructions).toHaveLength(2); + expect(postVaa.transaction.transaction.instructions).toHaveLength(1); + expect(create.transaction.transaction.instructions).toHaveLength(1); }); }); @@ -260,7 +260,7 @@ describe('TokenBridge Tests', () => { const xfer = tb.transfer(sender, recipient, token, amount, payload); expect(xfer).toBeTruthy(); - const allTxns = []; + const allTxns: SolanaUnsignedTransaction[] = []; for await (const tx of xfer) { allTxns.push(tx); } @@ -271,7 +271,7 @@ describe('TokenBridge Tests', () => { expect(xferTx!.chain).toEqual(chain); const { transaction } = xferTx; - expect(transaction.instructions).toHaveLength(6); + expect(transaction.transaction.instructions).toHaveLength(6); // ... }); @@ -285,7 +285,7 @@ describe('TokenBridge Tests', () => { ); expect(xfer).toBeTruthy(); - const allTxns = []; + const allTxns: SolanaUnsignedTransaction[] = []; for await (const tx of xfer) { allTxns.push(tx); } @@ -296,7 +296,7 @@ describe('TokenBridge Tests', () => { expect(xferTx.chain).toEqual(chain); const { transaction } = xferTx; - expect(transaction.instructions).toHaveLength(2); + expect(transaction.transaction.instructions).toHaveLength(2); }); }); }); diff --git a/platforms/solana/protocols/cctp/src/circleBridge.ts b/platforms/solana/protocols/cctp/src/circleBridge.ts index 6f20cbef1..837388413 100644 --- a/platforms/solana/protocols/cctp/src/circleBridge.ts +++ b/platforms/solana/protocols/cctp/src/circleBridge.ts @@ -18,6 +18,7 @@ import { SolanaChains, SolanaPlatform, SolanaPlatformType, + SolanaTransaction, SolanaUnsignedTransaction, } from '@wormhole-foundation/connect-sdk-solana'; import { MessageTransmitter, TokenMessenger } from '.'; @@ -105,13 +106,10 @@ export class SolanaCircleBridge senderPk, ); - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); const transaction = new Transaction(); - transaction.recentBlockhash = blockhash; transaction.feePayer = senderPk; transaction.add(ix); - - yield this.createUnsignedTx(transaction, 'CircleBridge.Redeem'); + yield this.createUnsignedTx({ transaction }, 'CircleBridge.Redeem'); } async *transfer( @@ -140,13 +138,11 @@ export class SolanaCircleBridge amount, ); - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); const transaction = new Transaction(); - transaction.recentBlockhash = blockhash; transaction.feePayer = senderPk; transaction.add(ix); - yield this.createUnsignedTx(transaction, 'CircleBridge.Transfer'); + yield this.createUnsignedTx({ transaction }, 'CircleBridge.Transfer'); } async isTransferCompleted(message: CircleBridge.Message): Promise { @@ -216,7 +212,7 @@ export class SolanaCircleBridge } private createUnsignedTx( - txReq: Transaction, + txReq: SolanaTransaction, description: string, parallelizable: boolean = false, ): SolanaUnsignedTransaction { diff --git a/platforms/solana/protocols/core/src/core.ts b/platforms/solana/protocols/core/src/core.ts index 1c7a21ee3..822239d53 100644 --- a/platforms/solana/protocols/core/src/core.ts +++ b/platforms/solana/protocols/core/src/core.ts @@ -15,6 +15,7 @@ import { SolanaPlatform, SolanaPlatformType, SolanaUnsignedTransaction, + SolanaTransaction, } from '@wormhole-foundation/connect-sdk-solana'; import { ChainId, @@ -30,14 +31,14 @@ import { } from '@wormhole-foundation/connect-sdk'; import { Wormhole as WormholeCoreContract } from './types'; import { - BridgeData, - createBridgeFeeTransferInstruction, createPostMessageInstruction, createPostVaaInstruction, createReadOnlyWormholeProgramInterface, createVerifySignaturesInstructions, + createBridgeFeeTransferInstruction, derivePostedVaaKey, getWormholeBridgeData, + BridgeData, } from './utils'; const SOLANA_SEQ_LOG = 'Program log: Sequence: '; @@ -82,6 +83,7 @@ export class SolanaWormholeCore throw new Error( `Network mismatch for chain ${chain}: ${conf.network} != ${network}`, ); + return new SolanaWormholeCore( network as N, chain, @@ -126,24 +128,20 @@ export class SolanaWormholeCore fee, ); - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); const transaction = new Transaction(); - transaction.recentBlockhash = blockhash; transaction.feePayer = payer; transaction.add(feeTransferIx, postMsgIx); - transaction.partialSign(messageAccount); - - yield this.createUnsignedTx(transaction, 'Core.PublishMessage'); + yield this.createUnsignedTx( + { transaction, signers: [messageAccount] }, + 'Core.PublishMessage', + ); } async *verifyMessage(sender: AnySolanaAddress, vaa: VAA) { yield* this.postVaa(sender, vaa); } - async *postVaa(sender: AnySolanaAddress, vaa: VAA, blockhash?: string) { - if (!blockhash) - ({ blockhash } = await SolanaPlatform.latestBlock(this.connection)); - + async *postVaa(sender: AnySolanaAddress, vaa: VAA) { const postedVaaAddress = derivePostedVaaKey( this.coreBridge.programId, Buffer.from(vaa.hash), @@ -170,11 +168,12 @@ export class SolanaWormholeCore const verifySigTx = new Transaction().add( ...verifySignaturesInstructions.slice(i, i + 2), ); - verifySigTx.recentBlockhash = blockhash; verifySigTx.feePayer = senderAddr; - verifySigTx.partialSign(signatureSet); - - yield this.createUnsignedTx(verifySigTx, 'Core.VerifySignature', true); + yield this.createUnsignedTx( + { transaction: verifySigTx, signers: [signatureSet] }, + 'Core.VerifySignature', + true, + ); } // Finally create the VAA posting transaction @@ -187,10 +186,9 @@ export class SolanaWormholeCore signatureSet.publicKey, ), ); - postVaaTx.recentBlockhash = blockhash; postVaaTx.feePayer = senderAddr; - yield this.createUnsignedTx(postVaaTx, 'Core.PostVAA'); + yield this.createUnsignedTx({ transaction: postVaaTx }, 'Core.PostVAA'); } static parseSequenceFromLog( @@ -327,7 +325,7 @@ export class SolanaWormholeCore } private createUnsignedTx( - txReq: Transaction, + txReq: SolanaTransaction, description: string, parallelizable: boolean = false, ): SolanaUnsignedTransaction { diff --git a/platforms/solana/protocols/tokenBridge/src/automaticTokenBridge.ts b/platforms/solana/protocols/tokenBridge/src/automaticTokenBridge.ts index d233685c7..9e26a174f 100644 --- a/platforms/solana/protocols/tokenBridge/src/automaticTokenBridge.ts +++ b/platforms/solana/protocols/tokenBridge/src/automaticTokenBridge.ts @@ -16,6 +16,7 @@ import { SolanaChains, SolanaPlatform, SolanaPlatformType, + SolanaTransaction, SolanaUnsignedTransaction, } from '@wormhole-foundation/connect-sdk-solana'; @@ -173,18 +174,18 @@ export class SolanaAutomaticTokenBridge< nonce, ); - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); - transaction.add(transferIx); - transaction.recentBlockhash = blockhash; transaction.feePayer = senderAddress; - yield this.createUnsignedTx(transaction, 'AutomaticTokenBridge.Transfer'); + yield this.createUnsignedTx( + { transaction }, + 'AutomaticTokenBridge.Transfer', + ); } async *redeem(sender: AccountAddress, vaa: AutomaticTokenBridge.VAA) { - const redeemTx = new Transaction(); - yield this.createUnsignedTx(redeemTx, 'AutomaticTokenBridge.Redeem'); + const transaction = new Transaction(); + yield this.createUnsignedTx({ transaction }, 'AutomaticTokenBridge.Redeem'); throw new Error('Method not implemented.'); } @@ -327,7 +328,7 @@ export class SolanaAutomaticTokenBridge< } private createUnsignedTx( - txReq: Transaction, + txReq: SolanaTransaction, description: string, parallelizable: boolean = false, ): SolanaUnsignedTransaction { diff --git a/platforms/solana/protocols/tokenBridge/src/tokenBridge.ts b/platforms/solana/protocols/tokenBridge/src/tokenBridge.ts index 3605705bb..82e90b02e 100644 --- a/platforms/solana/protocols/tokenBridge/src/tokenBridge.ts +++ b/platforms/solana/protocols/tokenBridge/src/tokenBridge.ts @@ -21,6 +21,7 @@ import { SolanaChains, SolanaPlatform, SolanaPlatformType, + SolanaTransaction, SolanaUnsignedTransaction, } from '@wormhole-foundation/connect-sdk-solana'; import { @@ -211,17 +212,17 @@ export class SolanaTokenBridge ): AsyncGenerator> { if (!payer) throw new Error('Payer required to create attestation'); - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); const senderAddress = new SolanaAddress(payer).unwrap(); // TODO: createNonce().readUInt32LE(0); const nonce = 0; const msgFee = await this.coreBridge.getMessageFee(); - const transferIx = await coreUtils.createBridgeFeeTransferInstruction( - this.coreBridge.address, + const transferIx = coreUtils.createBridgeFeeTransferInstruction( + this.coreBridge.coreBridge.programId, senderAddress, msgFee, ); + const messageKey = Keypair.generate(); const attestIx = createAttestTokenInstruction( this.connection, @@ -234,11 +235,11 @@ export class SolanaTokenBridge ); const transaction = new Transaction().add(transferIx, attestIx); - transaction.recentBlockhash = blockhash; transaction.feePayer = senderAddress; - transaction.partialSign(messageKey); - - yield this.createUnsignedTx(transaction, 'Solana.AttestToken'); + yield this.createUnsignedTx( + { transaction, signers: [messageKey] }, + 'Solana.AttestToken', + ); } async *submitAttestation( @@ -247,11 +248,10 @@ export class SolanaTokenBridge ): AsyncGenerator> { if (!payer) throw new Error('Payer required to create attestation'); - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); const senderAddress = new SolanaAddress(payer).unwrap(); // Yield transactions to verify sigs and post the VAA - yield* this.coreBridge.postVaa(senderAddress, vaa, blockhash); + yield* this.coreBridge.postVaa(senderAddress, vaa); // Now yield the transaction to actually create the token const transaction = new Transaction().add( @@ -263,10 +263,9 @@ export class SolanaTokenBridge vaa, ), ); - transaction.recentBlockhash = blockhash; transaction.feePayer = senderAddress; - yield this.createUnsignedTx(transaction, 'Solana.CreateWrapped'); + yield this.createUnsignedTx({ transaction }, 'Solana.CreateWrapped'); } private async transferSol( @@ -277,7 +276,6 @@ export class SolanaTokenBridge ): Promise> { // https://github.com/wormhole-foundation/wormhole-connect/blob/development/sdk/src/contexts/solana/context.ts#L245 - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); const senderAddress = new SolanaAddress(sender).unwrap(); // TODO: the payer can actually be different from the sender. We need to allow the user to pass in an optional payer @@ -367,7 +365,6 @@ export class SolanaTokenBridge ); const transaction = new Transaction(); - transaction.recentBlockhash = blockhash; transaction.feePayer = payerPublicKey; transaction.add( createAncillaryAccountIx, @@ -377,9 +374,10 @@ export class SolanaTokenBridge tokenBridgeTransferIx, closeAccountIx, ); - transaction.partialSign(message, ancillaryKeypair); - - return this.createUnsignedTx(transaction, 'TokenBridge.TransferNative'); + return this.createUnsignedTx( + { transaction, signers: [message, ancillaryKeypair] }, + 'TokenBridge.TransferNative', + ); } async *transfer( @@ -396,7 +394,6 @@ export class SolanaTokenBridge return; } - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); const tokenAddress = new SolanaAddress(token).unwrap(); const senderAddress = new SolanaAddress(sender).unwrap(); const senderTokenAddress = await getAssociatedTokenAddress( @@ -496,17 +493,16 @@ export class SolanaTokenBridge tokenBridgeTransferIx, ); - transaction.recentBlockhash = blockhash; transaction.feePayer = senderAddress; - transaction.partialSign(message); - - yield this.createUnsignedTx(transaction, 'TokenBridge.TransferTokens'); + yield this.createUnsignedTx( + { transaction, signers: [message] }, + 'TokenBridge.TransferTokens', + ); } private async *redeemAndUnwrap( sender: AnySolanaAddress, vaa: TokenBridge.TransferVAA, - blockhash: string, ) { // sender, fee payer const payerPublicKey = new SolanaAddress(sender).unwrap(); @@ -565,7 +561,6 @@ export class SolanaTokenBridge ); const transaction = new Transaction(); - transaction.recentBlockhash = blockhash; transaction.feePayer = payerPublicKey; transaction.add( completeTransferIx, @@ -574,15 +569,13 @@ export class SolanaTokenBridge balanceTransferIx, closeAccountIx, ); - transaction.partialSign(ancillaryKeypair); - yield this.createUnsignedTx(transaction, 'TokenBridge.RedeemAndUnwrap'); + yield this.createUnsignedTx( + { transaction, signers: [ancillaryKeypair] }, + 'TokenBridge.RedeemAndUnwrap', + ); } - private async *createAta( - sender: AnySolanaAddress, - token: AnySolanaAddress, - blockhash: string, - ) { + private async *createAta(sender: AnySolanaAddress, token: AnySolanaAddress) { const senderAddress = new SolanaAddress(sender).unwrap(); const tokenAddress = new SolanaAddress(token).unwrap(); @@ -591,7 +584,7 @@ export class SolanaTokenBridge // If the ata doesn't exist yet, create it const acctInfo = await this.connection.getAccountInfo(ata); if (acctInfo === null) { - const ataCreationTx = new Transaction().add( + const transaction = new Transaction().add( createAssociatedTokenAccountInstruction( senderAddress, ata, @@ -599,9 +592,8 @@ export class SolanaTokenBridge tokenAddress, ), ); - ataCreationTx.feePayer = senderAddress; - ataCreationTx.recentBlockhash = blockhash; - yield this.createUnsignedTx(ataCreationTx, 'Redeem.CreateATA'); + transaction.feePayer = senderAddress; + yield this.createUnsignedTx({ transaction }, 'Redeem.CreateATA'); } } @@ -610,8 +602,6 @@ export class SolanaTokenBridge vaa: TokenBridge.TransferVAA, unwrapNative: boolean = true, ) { - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); - // Find the token address local to this chain const nativeAddress = vaa.payload.token.chain === this.chain @@ -619,10 +609,10 @@ export class SolanaTokenBridge : (await this.getWrappedAsset(vaa.payload.token)).toUniversalAddress(); // Create an ATA if necessary - yield* this.createAta(sender, nativeAddress, blockhash); + yield* this.createAta(sender, nativeAddress); // Post the VAA if necessary - yield* this.coreBridge.postVaa(sender, vaa, blockhash); + yield* this.coreBridge.postVaa(sender, vaa); // redeem vaa and unwrap to native sol from wrapped sol if (unwrapNative) { @@ -634,7 +624,7 @@ export class SolanaTokenBridge wrappedNative.toUint8Array(), ) ) { - yield* this.redeemAndUnwrap(sender, vaa, blockhash); + yield* this.redeemAndUnwrap(sender, vaa); return; } } @@ -655,14 +645,12 @@ export class SolanaTokenBridge vaa, ), ); - - transaction.recentBlockhash = blockhash; transaction.feePayer = senderAddress; - yield this.createUnsignedTx(transaction, 'Solana.RedeemTransfer'); + yield this.createUnsignedTx({ transaction }, 'Solana.RedeemTransfer'); } private createUnsignedTx( - txReq: Transaction, + txReq: SolanaTransaction, description: string, parallelizable: boolean = false, ): SolanaUnsignedTransaction { diff --git a/platforms/solana/src/platform.ts b/platforms/solana/src/platform.ts index db3a9dc91..e950ca7ce 100644 --- a/platforms/solana/src/platform.ts +++ b/platforms/solana/src/platform.ts @@ -19,6 +19,7 @@ import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; import { Commitment, Connection, + ConnectionConfig, ParsedAccountData, PublicKey, SendOptions, @@ -49,10 +50,13 @@ export class SolanaPlatform extends PlatformContext< getRpc( chain: C, - commitment: Commitment = 'confirmed', + config: ConnectionConfig = { + commitment: 'confirmed', + disableRetryOnRateLimit: true, + }, ): Connection { if (chain in this.config) - return new Connection(this.config[chain]!.rpc, commitment); + return new Connection(this.config[chain]!.rpc, config); throw new Error('No configuration available for chain: ' + chain); } @@ -167,18 +171,18 @@ export class SolanaPlatform extends PlatformContext< opts?: SendOptions, ): Promise { const { blockhash, lastValidBlockHeight } = await this.latestBlock(rpc); - - // Set the commitment level to match the rpc commitment level - // otherwise, it defaults to finalized - if (!opts) opts = { preflightCommitment: rpc.commitment }; - const txhashes = await Promise.all( - stxns.map((stxn) => { - return rpc.sendRawTransaction(stxn, opts); - }), + stxns.map((stxn) => + rpc.sendRawTransaction( + stxn, + // Set the commitment level to match the rpc commitment level + // otherwise, it defaults to finalized + opts ?? { preflightCommitment: rpc.commitment }, + ), + ), ); - await Promise.all( + const results = await Promise.all( txhashes.map((signature) => { return rpc.confirmTransaction( { @@ -191,6 +195,13 @@ export class SolanaPlatform extends PlatformContext< }), ); + const erroredTxs = results + .filter((result) => result.value.err) + .map((result) => result.value.err); + + if (erroredTxs.length > 0) + throw new Error(`Failed to confirm transaction: ${erroredTxs}`); + return txhashes; } @@ -198,7 +209,7 @@ export class SolanaPlatform extends PlatformContext< rpc: Connection, commitment?: Commitment, ): Promise<{ blockhash: string; lastValidBlockHeight: number }> { - return rpc.getLatestBlockhash(commitment ?? 'finalized'); + return rpc.getLatestBlockhash(commitment ?? rpc.commitment); } static async getLatestBlock(rpc: Connection): Promise { diff --git a/platforms/solana/src/testing/debug.ts b/platforms/solana/src/testing/debug.ts new file mode 100644 index 000000000..67a853e32 --- /dev/null +++ b/platforms/solana/src/testing/debug.ts @@ -0,0 +1,14 @@ +import { Transaction } from '@solana/web3.js'; + +export function logTxDetails(transaction: Transaction) { + console.log(transaction.signatures); + console.log(transaction.feePayer); + transaction.instructions.forEach((ix) => { + console.log('Program', ix.programId.toBase58()); + console.log('Data: ', ix.data.toString('hex')); + console.log( + 'Keys: ', + ix.keys.map((k) => [k, k.pubkey.toBase58()]), + ); + }); +} diff --git a/platforms/solana/src/testing/index.ts b/platforms/solana/src/testing/index.ts index b53f40df4..768a910ee 100644 --- a/platforms/solana/src/testing/index.ts +++ b/platforms/solana/src/testing/index.ts @@ -13,6 +13,7 @@ export async function getSolanaSigner( return new SolanaSigner( chain, Keypair.fromSecretKey(encoding.b58.decode(privateKey)), + rpc, ); } diff --git a/platforms/solana/src/testing/sendSigner.ts b/platforms/solana/src/testing/sendSigner.ts index a0764dfc7..19188e9c6 100644 --- a/platforms/solana/src/testing/sendSigner.ts +++ b/platforms/solana/src/testing/sendSigner.ts @@ -1,4 +1,11 @@ -import { Connection, Keypair } from '@solana/web3.js'; +import { + ComputeBudgetProgram, + Connection, + Keypair, + SendOptions, + SendTransactionError, + TransactionExpiredBlockheightExceededError, +} from '@solana/web3.js'; import { SignAndSendSigner, UnsignedTransaction, @@ -7,6 +14,10 @@ import { Network } from '@wormhole-foundation/sdk-base/src'; import { SolanaPlatform } from '../platform'; import { SolanaChains } from '../types'; import { SolanaUnsignedTransaction } from '../unsignedTransaction'; +import { logTxDetails } from './debug'; + +// Number of blocks to wait before considering a transaction expired +const SOLANA_EXPIRED_BLOCKHEIGHT = 150; export class SolanaSendSigner< N extends Network, @@ -18,7 +29,13 @@ export class SolanaSendSigner< private _chain: C, private _keypair: Keypair, private _debug: boolean = false, - ) {} + private _sendOpts?: SendOptions, + private _priotifyFeeAmount?: bigint, + ) { + this._sendOpts = this._sendOpts ?? { + preflightCommitment: this._rpc.commitment, + }; + } chain(): C { return this._chain; @@ -28,50 +45,121 @@ export class SolanaSendSigner< return this._keypair.publicKey.toBase58(); } - async signAndSend(tx: UnsignedTransaction[]): Promise { - const { blockhash, lastValidBlockHeight } = - await SolanaPlatform.latestBlock(this._rpc, 'finalized'); + // Handles retrying a Transaction if the error is deemed to be + // recoverable. Currently handles: + // - Transaction expired + // - Blockhash not found + // - Not enough bytes (storage account not seen yet) + private retryable(e: any): boolean { + // Tx expired, set a new block hash and retry + if (e instanceof TransactionExpiredBlockheightExceededError) return true; + + // Besides tx expiry, only handle SendTransactionError + if (!(e instanceof SendTransactionError)) return false; + + // Only handle simulation errors + if (!e.message.includes('Transaction simulation failed')) return false; - const txPromises: Promise[] = []; + // Blockhash not found, similar to expired, resend with new blockhash + if (e.message.includes('Blockhash not found')) return true; + // Find the log message with the error details + const loggedErr = e.logs.find((log) => + log.startsWith('Program log: Error: '), + ); + + // who knows + if (!loggedErr) return false; + + // Probably caused by storage account not seen yet + if (loggedErr.includes('Not enough bytes')) return true; + if (loggedErr.includes('Unexpected length of input')) return true; + + return false; + } + + async signAndSend(tx: UnsignedTransaction[]): Promise { + let { blockhash, lastValidBlockHeight } = await SolanaPlatform.latestBlock( + this._rpc, + ); + + const txids: string[] = []; for (const txn of tx) { - const { description, transaction } = txn as SolanaUnsignedTransaction< - N, - C - >; + const { + description, + transaction: { transaction, signers: extraSigners }, + } = txn as SolanaUnsignedTransaction; console.log(`Signing: ${description} for ${this.address()}`); - if (this._debug) { - console.log(transaction.signatures); - console.log(transaction.feePayer); - transaction.instructions.forEach((ix) => { - console.log('Program', ix.programId.toBase58()); - console.log('Data: ', ix.data.toString('hex')); - console.log( - 'Keys: ', - ix.keys.map((k) => [k, k.pubkey.toBase58()]), + if (this._priotifyFeeAmount) + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: this._priotifyFeeAmount, + }), + ); + + if (this._debug) logTxDetails(transaction); + + // Try to send the transaction up to 5 times + const maxRetries = 5; + for (let i = 0; i < maxRetries; i++) { + try { + transaction.recentBlockhash = blockhash; + transaction.partialSign(this._keypair, ...(extraSigners ?? [])); + + const txid = await this._rpc.sendRawTransaction( + transaction.serialize(), + this._sendOpts, ); - }); - } + txids.push(txid); + break; + } catch (e) { + // No point checking if retryable if we're on the last retry + if (i === maxRetries - 1) throw e; + + // If it's not retryable, throw + if (!this.retryable(e)) throw e; - transaction.partialSign(this._keypair); + // If it is retryable, we need to grab a new block hash + const { + blockhash: newBlockhash, + lastValidBlockHeight: newBlockHeight, + } = await SolanaPlatform.latestBlock(this._rpc); - txPromises.push( - this._rpc.sendRawTransaction(transaction.serialize(), { - preflightCommitment: this._rpc.commitment, - }), - ); + // But we should _not_ submit if the blockhash hasnt expired + if ( + newBlockHeight - lastValidBlockHeight < + SOLANA_EXPIRED_BLOCKHEIGHT + ) { + throw e; + } + + lastValidBlockHeight = newBlockHeight; + blockhash = newBlockhash; + } + } } - const txids = await Promise.all(txPromises); // Wait for finalization - for (const signature of txids) { - await this._rpc.confirmTransaction({ - signature, - blockhash, - lastValidBlockHeight, - }); - } + const results = await Promise.all( + txids.map((signature) => + this._rpc.confirmTransaction( + { + signature, + blockhash, + lastValidBlockHeight, + }, + this._rpc.commitment, + ), + ), + ); + + const erroredTxs = results + .filter((result) => result.value.err) + .map((result) => result.value.err); + + if (erroredTxs.length > 0) + throw new Error(`Failed to confirm transaction: ${erroredTxs}`); return txids; } diff --git a/platforms/solana/src/testing/signer.ts b/platforms/solana/src/testing/signer.ts index 4c1a196af..ddca70694 100644 --- a/platforms/solana/src/testing/signer.ts +++ b/platforms/solana/src/testing/signer.ts @@ -1,10 +1,12 @@ -import { Keypair, Transaction } from '@solana/web3.js'; +import { Connection, Keypair } from '@solana/web3.js'; import { SignOnlySigner, UnsignedTransaction, } from '@wormhole-foundation/connect-sdk'; import { Network } from '@wormhole-foundation/sdk-base/src'; +import { SolanaPlatform } from '../platform'; import { SolanaChains } from '../types'; +import { logTxDetails } from './debug'; export class SolanaSigner implements SignOnlySigner @@ -12,6 +14,7 @@ export class SolanaSigner constructor( private _chain: C, private _keypair: Keypair, + private _rpc: Connection, private _debug: boolean = false, ) {} @@ -24,25 +27,21 @@ export class SolanaSigner } async sign(tx: UnsignedTransaction[]): Promise { + const { blockhash } = await SolanaPlatform.latestBlock(this._rpc); + const signed = []; for (const txn of tx) { - const { description, transaction } = txn; + const { + description, + transaction: { transaction, signers: extraSigners }, + } = txn; + console.log(`Signing: ${description} for ${this.address()}`); - if (this._debug) { - const st = transaction as Transaction; - console.log(st.signatures); - console.log(st.feePayer); - st.instructions.forEach((ix) => { - console.log('Program', ix.programId.toBase58()); - console.log('Data: ', ix.data.toString('hex')); - ix.keys.forEach((k) => { - console.log(k, k.pubkey.toBase58()); - }); - }); - } - - transaction.partialSign(this._keypair); + if (this._debug) logTxDetails(transaction); + + transaction.recentBlockhash = blockhash; + transaction.partialSign(this._keypair, ...(extraSigners ?? [])); signed.push(transaction.serialize()); } return signed; diff --git a/platforms/solana/src/unsignedTransaction.ts b/platforms/solana/src/unsignedTransaction.ts index ecb030632..df718fc1b 100644 --- a/platforms/solana/src/unsignedTransaction.ts +++ b/platforms/solana/src/unsignedTransaction.ts @@ -1,14 +1,19 @@ -import { Transaction } from '@solana/web3.js'; +import { Keypair, Transaction } from '@solana/web3.js'; import { Network, UnsignedTransaction } from '@wormhole-foundation/connect-sdk'; import { SolanaChains } from './types'; +export type SolanaTransaction = { + transaction: Transaction; + signers?: Keypair[]; +}; + export class SolanaUnsignedTransaction< N extends Network, C extends SolanaChains = SolanaChains, > implements UnsignedTransaction { constructor( - readonly transaction: Transaction, + readonly transaction: SolanaTransaction, readonly network: N, readonly chain: C, readonly description: string,