How to Start Building with the Solana Web3.js 2.0 SDK
Before we dive in, I want to thank Evan and Nick for reviewing this article. Their valuable feedback and insights were greatly appreciated.
Introduction
The Solana Web3.js SDK is a powerful JavaScript library for building Solana applications across Node.js, web, and React Native platforms. On November 7, 2024, Anza Labs launched the highly anticipated Web3.js 2.0 SDK, introducing a host of modern JavaScript features and improvements. Key highlights include tree-shakability and reduced bundle sizes, making it a significant upgrade for developers.
If you’ve authored developer content or shell scripts that automate the installation of @solana/web3.js, you’ll need to decide between two options: either port your software to the new v2.0 API or lock it to v1.x by explicitly specifying the version.
In this article, we’ll explore the latest updates in the Web3.js 2.0 SDK, guide you through the migration process, and provide an example to help you get started.
Before we dive in, it’s assumed you have a solid understanding of foundational Solana concepts, such as sending transactions, the account model, blockhashes, and priority fees, along with experience in JavaScript. While familiarity with the previous version of the Web3.js SDK is recommended, it is not mandatory. Let’s get started!
What’s New in Web3.js 2.0?
We will quickly explore what the new Web3.js 2.0 SDK has to offer:
1. Performance Improvements
Faster Cryptographic Operations: Keypair generation, transaction signing, and message verification are up to 10x faster, leveraging native Ed25519 cryptography APIs in modern JavaScript environments like Node.js and Safari 17
2. Smaller and Efficient Applications
Web3.js 2.0 is fully tree-shakable, allowing you to include only the parts of the library you use, minimizing your bundle size. Additionally, the new SDK has zero external dependencies, ensuring a lightweight and secure build.
3. Enhanced Flexibility
Developers can now create custom solutions by:
- Defining RPC instances with custom methods
- Using specialized network transports or transaction signers
- Composing custom primitives for networking, transaction confirmations, and codecs
The new TypeScript clients for on-chain programs are now hosted in the @solana-program GitHub organization. These clients are autogenerated using Codama IDLs, enabling developers to generate clients for custom programs quickly.
How to Send Transactions with Web3.js 2.0
We will build a program using Web3.js 2.0 to transfer lamports to another wallet. This program will showcase techniques to enhance transaction success rates and faster confirmation times.
We will adhere to these best practices for sending transactions:
- Fetch the latest blockhash with a confirmed commitment level
- Set priority fees as recommended by Helius’ Priority Fee API
- Optimize compute units
- Send the transaction with maxRetries set to 0 and skipPreflight set to true
This approach ensures optimal performance and reliability, even during network congestion.
Prerequisites
- Install Node.js
- A compatible IDE (e.g., VS Code)
Installation
Start by creating a basic Node.js project to structure your application. Run the following command to create a package.json file that manages your dependencies and project metadata:
npm init -y
Create a src directory, and within it, add an index.js file where the main code will reside:
mkdir src
touch src/index.js
Next, use npm to install the necessary dependencies for working with Solana's Web3.js 2.0 SDK:
npm install @solana/web3.js@2 && npm install @solana-program/system && npm install @solana-program/compute-budget
- @solana/web3.js: The Solana Web3.js 2.0 SDK is essential for building and managing Solana transactions
- @solana-program/system: Provides access to the Solana System Program, enabling operations like lamport transfers
- @solana-program/compute-budget: Used for setting priority fees and optimizing compute units for transactions
Define Transfer Addresses
In index.js, let's define the source and destination addresses for transferring lamports. We will use the address() function to generate the destination public key from the provided string. For the source, we will derive the KeyPair using its secretKey.
import { address, createKeyPairSignerFromBytes, getBase58Encoder } from '@solana/web3.js';
async function main() {
const destinationAddress = address('public-key-to-send-lamports-to');
const secretKey = "add-your-private-key";
const sourceKeypair = await createKeyPairSignerFromBytes(
getBase58Encoder().encode(secretKey)
);
}
main();
Configure RPC Connections
Next, we can set up the relevant RPC connections. The createSolanaRpc function establishes communication with the RPC server using a default HTTP transport, which is sufficient for most use cases. Similarly, we use createSolanaRpcSubscriptions to establish a WebSocket connection. You can find the rpc_url and wss_url in the Helius Dashboard—simply sign up or log in and navigate to the Endpoints section.
The sendAndConfirmTransactionFactory function builds a reusable transaction sender. This sender requires an RPC connection to send transactions and an RPC subscription to monitor transaction status.
import {
// ...
createSolanaRpcSubscriptions,
createSolanaRpc,
sendAndConfirmTransactionFactory
} from '@solana/web3.js';
async function main() {
// ...
const rpc_url = "https://mainnet.helius-rpc.com/?api-key=<your-key>";
const wss_url = "wss://mainnet.helius-rpc.com/?api-key=<your-key>";
const rpc = createSolanaRpc(rpc_url);
const rpcSubscriptions = createSolanaRpcSubscriptions(wss_url);
const sendAndConfirmTransaction = sendAndConfirmTransactionFactory({
rpc,
rpcSubscriptions
});
}
Create a Transfer Instruction
Blockhash prevents duplication and provides transactions with lifetimes—every transaction has to include a valid blockhash to be accepted for execution. For this transaction, we’ll fetch the latest blockhash using the confirmed commitment level.
Next, we'll use the getTransferSolInstruction function to create a predefined transfer instruction provided by the System Program. This requires specifying the amount, source, and destination. The source must always be a Signer, while the destination should be a public address.
import {
// ...
lamports,
} from '@solana/web3.js';
import { getTransferSolInstruction } from '@solana-program/system';
async function main() {
// ...
/**
* STEP 1: CREATE THE TRANSFER TRANSACTION
*/
const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
const instruction = getTransferSolInstruction({
amount: lamports(1),
destination: toPubkey,
source: fromKeypair,
});
}
Create Transaction Message
We will then create the transaction message. All transaction messages are now version-aware, eliminating the need to handle different types (e.g., Transaction vs. VersionedTransaction).
We'll set the source as the fee payer, include the blockhash, and add the instruction for transferring lamports.
import {
// ...
pipe,
createTransactionMessage,
setTransactionMessageFeePayer,
setTransactionMessageLifetimeUsingBlockhash,
appendTransactionMessageInstruction,
} from '@solana/web3.js';
async function main() {
// ...
const transactionMessage = pipe(
createTransactionMessage({ version: 0 }),
tx => (
setTransactionMessageFeePayer(fromKeypair.address, tx)
),
tx => (
setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx)
),
tx =>
appendTransactionMessageInstruction(
instruction,
tx,
),
);
console.log("Transaction message created");
}
The pipe function, commonly used in functional programming, creates a sequence of functions where the output of one becomes the input of the next. Here, it builds a transaction message step by step, applying transformations like setting the fee payer and lifetime and adding instructions.
- Initialize Transaction Message:
createTransactionMessage({ version: 0 })
starts with a basic transaction message. - Set Fee Payer:
tx => setTransactionMessageFeePayer(fromKeypair.address, tx)
adds the fee payer's address. - Set Lifetime Using Blockhash:
tx => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx)
ensures the transaction is valid within a timeframe using the latest blockhash. - Add Transfer Instruction:
tx => appendTransactionMessageInstruction(instruction, tx)
appends the action (e.g., transferring lamports) to the message.
Each arrow function tx => (...) modifies and passes the updated message to the next step, producing an entirely constructed new transaction message.
Sign the Transaction
We will sign the transaction using the specified signer, the source Keypair.
import {
// ...
signTransactionMessageWithSigners
} from '@solana/web3.js';
async function sendTransaction() {
// ...
/**
* STEP 2: SIGN THE TRANSACTION
*/
const signedTransaction = await signTransactionMessageWithSigners(transactionMessage);
console.log("Transaction signed");
}
Estimate Priority Fees
At this step, we can move forward with sending and confirming the transaction. However, we should optimize the transaction by setting priority fees and adjusting compute units. These optimizations help improve transaction success rates and reduce confirmation times, especially during network congestion.
To set priority fees, we will use Helius' Priority Fee API. This requires the serialized transaction in Base64 format. While the API also supports Base58 encoding, the current SDK directly provides the transaction in Base64 format, simplifying the process.
import {
// ...
getBase64EncodedWireTransaction
} from '@solana/web3.js';
async function sendTransaction() {
// ...
/**
* STEP 3: GET PRIORITY FEE FROM SIGNED TRANSACTION
*/
const base64EncodedWireTransaction = getBase64EncodedWireTransaction(signedTransaction);
const response = await fetch(rpc_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'helius-example',
method: 'getPriorityFeeEstimate',
params: [{
transaction: base64EncodedWireTransaction,
options: {
transactionEncoding: "base64",
recommended: true,
}
}]
}),
});
const { result } = await response.json();
const priorityFee = result.priorityFeeEstimate;
console.log("Setting priority fee to ", priorityFee);
}
Setting the recommended priority fee is usually sufficient. However, implementing advanced priority fee strategies can significantly improve transaction success rates during network congestion.
Optimize Compute Units
Next, we will estimate the actual compute units the transaction message consumes. Then, we add a 10% buffer by multiplying this value by 1.1. This buffer accounts for the compute units used by priority fees and additional compute unit instructions, which we will incorporate later.
Some instructions, such as transferring lamports, may have a lower compute unit estimate. To ensure sufficient resources, we’ve added a safeguard to set the compute units to a minimum of 1000 if the estimate falls below this threshold.
import {
// ...
getComputeUnitEstimateForTransactionMessageFactory
} from '@solana/web3.js';
async function sendTransaction() {
// ...
/**
* STEP 4: OPTIMIZE COMPUTE UNITS
*/
const getComputeUnitEstimateForTransactionMessage = getComputeUnitEstimateForTransactionMessageFactory({
rpc
});
// Request an estimate of the actual compute units this message will consume.
let computeUnitsEstimate = await getComputeUnitEstimateForTransactionMessage(transactionMessage);
computeUnitsEstimate = (computeUnitsEstimate < 1000) ? 1000 : Math.ceil(computeUnitsEstimate * 1.1);
console.log("Setting compute units to ", computeUnitsEstimate);
}
Rebuild and Sign the Transaction
We now have the priority fees and compute units required for this transaction. Since the transaction has already been signed, we cannot directly add new instructions. Instead, we will rebuild the entire transaction message with a new blockhash.
Blockhashes are only valid for about 1-2 minutes, and fetching the priority fees and compute units takes some time. To avoid the risk of the blockhash expiring while sending the transaction, it’s safer to get a new one while rebuilding the transaction.
In this rebuilt transaction, we will include two additional instructions:
- One instruction to set the priority fees and;
- Another instruction to set the compute units
Finally, we will sign this updated transaction to prepare it for submission:
import {
// ...
appendTransactionMessageInstructions,
} from '@solana/web3.js';
import { getSetComputeUnitLimitInstruction, getSetComputeUnitPriceInstruction } from '@solana-program/compute-budget';
async function sendTransaction() {
// ...
/**
* STEP 5: REBUILD AND SIGN FINAL TRANSACTION
*/
const { value: finalLatestBlockhash } = await rpc.getLatestBlockhash().send();
const finalTransactionMessage = appendTransactionMessageInstructions(
[
getSetComputeUnitPriceInstruction({ microLamports: priorityFee }),
getSetComputeUnitLimitInstruction({ units: computeUnitsEstimate })
],
transactionMessage,
);
setTransactionMessageLifetimeUsingBlockhash(finalLatestBlockhash, finalTransactionMessage);
const finalSignedTransaction = await signTransactionMessageWithSigners(finalTransactionMessage);
console.log("Rebuilt the transaction and signed it");
}
Send and Confirm Transaction
Next, the signed transaction is sent and confirmed using the sendAndConfirmTransaction function. The commitment level is set to confirmed, consistent with the blockhash fetched earlier, while maxRetries is set to 0. The skipPreflight option is set to true, bypassing preflight checks for faster execution; however, this should only be used when you are confident that your transaction signature is verified and there are no other errors.
The sendAndConfirmTransaction was created earlier by providing both the RPC and RPC subscription URLs. Using the RPC subscription URL checks the transaction status, eliminating the need for manual polling.
In the error handling section, the code checks for errors that occurred during the preflight checks. Since we set skipPreflight to true, this check is redundant. However, it will be helpful if you do not set it to true.
import {
getSignatureFromTransaction,
isSolanaError,
SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE
} from '@solana/web3.js';
import { getSystemErrorMessage, isSystemError } from '@solana-program/system';
async function sendTransaction() {
// ...
/**
* STEP 6: SEND AND CONFIRM THE FINAL TRANSACTION
*/
try {
console.log("Sending and confirming transaction");
await sendAndConfirmTransaction(finalSignedTransaction, { commitment: 'confirmed', maxRetries: 0, skipPreflight: true});
console.log('Transfer confirmed: ', getSignatureFromTransaction(finalSignedTransaction));
} catch (e) {
if (isSolanaError(e, SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE)) {
const preflightErrorContext = e.context;
const preflightErrorMessage = e.message;
const errorDetailMessage = isSystemError(e.cause, finalTransactionMessage) ?
getSystemErrorMessage(e.cause.context.code) : e.cause ? e.cause.message : '';
console.error(preflightErrorContext, '%s: %s', preflightErrorMessage, errorDetailMessage);
} else {
throw e;
}
}
}
Run the Code
Finally, we can run the code:
Conclusion
The release of Solana's Web3.js 2.0 SDK is a transformative update that empowers developers to create faster, more efficient, and scalable applications on Solana. By embracing modern JavaScript standards and introducing features like native cryptographic APIs, tree-shakability, and autogenerated TypeScript clients, the SDK significantly enhances the developer experience and application performance.
The complete code for the programming example is on GitHub. The Solana Foundation has also provided other examples on GitHub, which are listed in the Resources section below.
If you’ve read this far, thank you, anon! Be sure to enter your email address below so you’ll never miss an update about what’s new on Solana. Ready to dive deeper? Explore the latest articles on the Helius blog and continue your Solana journey today.