Parse Solana Jupiter transactions with web3js 2.0

A follow up to my previous post on decoding Solana Transactions on a budget.

It’s still useful to read if you want to understand how transactions are decoded. Whereas this article focuses on implementation using web3js 2.0.

Background

The release of web3js 2.0 addresses the main concern of bundle size. I would recommend it as the library of choice now when decoding Solana transactions.

That being said, there are not many practical examples on how to work with the library. So I hope this will be a useful resource.

My goal here is to parse the output amount of a Jupiter swap given a transaction id.

Getting transaction data

We start with creating a RPC client and getting the transaction data.

const rpc = createSolanaRpc("https://rpc...");
const txId = signature(
"4kSiJXqP5SZsVsURBQYyVs1kJqDqsC195nwP9ZgTQ62q3d4UyXHMgLg2HaQvgy5jr9wykpfQoxCJ2ErU53JzFzPZ"
);
const rpcTx = await rpc
.getTransaction(txId, {
commitment: "confirmed",
maxSupportedTransactionVersion: 0,
})
.send();
if (!rpcTx) {
throw new Error("Unable to fetch transaction");
}
console.log("Tx landed at slot", rpcTx.slot);

Parsing the Jupiter swap transaction

We now need to identify the Jupiter program instruction and the inner instructions.

const JUPITER_AGGREGATOR_V6 = address(
"JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"
);
// Find the swap instruction
const swapIxIdx = rpcTx.transaction.message.instructions.findIndex((ix) => {
const program = rpcTx.transaction.message.accountKeys[ix.programIdIndex];
return program === JUPITER_AGGREGATOR_V6;
});
if (swapIxIdx === -1) {
throw new Error("Unable to find Jupiter Swap instruction");
}
// Find the inner instructions
const innerIxs = rpcTx.meta?.innerInstructions?.find(
(innerIx) => innerIx.index === swapIxIdx
)?.instructions;
if (!innerIxs) {
throw new Error("Unable to find Jupiter Swap inner instructions");
}
console.log("Inner instructions", innerIxs);

The Jupiter program emits a Swap Event, which contains the output amount. This is the only event we care about. We can filter for it based on the discriminator.

import { sha256 } from "@noble/hashes/sha256";
const SWAP_EVENT_DISCRIMINATOR = sha256(`event:SwapEvent`).subarray(0, 8);
// [
// 64, 198, 205, 232,
// 38, 8, 113, 226
// ]
// Helper function to compare discriminators
function isBytesEqual(a: Uint8Array, b: Uint8Array) {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
for (const ix of innerIxs) {
const ixBytes = getBase58Encoder().encode(ix.data);
const eventBytes = ixBytes.subarray(8); // skip ix discriminator
if (
!isBytesEqual(
eventBytes.subarray(0, 8), // event discriminator
SWAP_EVENT_DISCRIMINATOR
)
) {
continue;
}
// we have filtered for the Swap Event
}

Now that we identified the event, we can deserialize the bytes. This is where the new web3js 2.0 comes in. It exports a bunch of decoders for data types that we can use to define a custom struct layout.

In our case, our swap event struct looks like this.

const SwapEventDecoder = getStructDecoder([
["amm", getAddressDecoder()],
["inputMint", getAddressDecoder()],
["inputAmount", getU64Decoder()],
["outputMint", getAddressDecoder()],
["outputAmount", getU64Decoder()],
]);

We make use of this decoder to decode the bytes and return the output amount.

for (const ix of innerIxs) {
...
// filter for the Swap Event
const swapEvent = SwapEventDecoder.decode(eventBytes.subarray(8));
return {
outputAmount: swapEvent.outputAmount.toString(),
};
}

Full code for this example can be found here.

Whats the bundle size? It clocks in at 9.48 kB -> 4.48 kB (gzip) (stackblitz) which is great. If you statically define the SWAP_EVENT_DISCRIMINATOR bytes this goes down to 4.82 kB -> 2.33 kB (gzip).

You can skip the next section if you are not interested in reading about my initial problems testing this.

Investigating the bundle size

When doing my initial test the bundle size was 28.1 kB using bundlejs.

While an acceptable size, I couldn’t help but wonder what was actually contributing to the size as the minimal implementation I did in the previous post was 7.82 kB.

Here is a bundle analysis visualisation of the parsing code.

Bundle analysis visualisation

It turns out 43.73% comes from the @solana/errors package. This seems large. Eyeballing the build output there are huge chunks of texts that seem to be error messages.

Build output error text

Trying to identify why this was included I checked the source code and found the SolanaErrorMessages object that contains all the error messages.

export const SolanaErrorMessages = {
[SOLANA_ERROR__ACCOUNTS__ACCOUNT_NOT_FOUND]:
"Account not found at address: $address",
[SOLANA_ERROR__ACCOUNTS__EXPECTED_ALL_ACCOUNTS_TO_BE_DECODED]:
"Not all accounts were decoded. Encoded accounts found at addresses: $addresses.",
[SOLANA_ERROR__ACCOUNTS__EXPECTED_DECODED_ACCOUNT]:
"Expected decoded account at address: $address",
[SOLANA_ERROR__ACCOUNTS__FAILED_TO_DECODE_ACCOUNT]:
"Failed to decode account data at address: $address",
...
};

I traced the code to find out where it’s used. The library authors were actually quite smart about this.

export function getHumanReadableErrorMessage(code, context = {}): string {
// Usage here
const messageFormatString = SolanaErrorMessages[code];
...
}
export function getErrorMessage(code, context = {}) {
if (__DEV__) {
// Called here
return getHumanReadableErrorMessage(code, context);
} else {
let decodingAdviceMessage = `Solana error #${code}; Decode this error by running \`npx @solana/errors decode -- ${code}`;
if (Object.keys(context).length) {
decodingAdviceMessage += ` '${encodeContextObject(context)}'`;
}
return `${decodingAdviceMessage}\``;
}
}

The human readable error messages are used only when the __DEV__ flag is set. This is a nice way to prevent the error messages from being included in the final production bundle. I wonder if a lazy loading approach might be good for when these errors show up in production. Where the messages are code split from the main bundle and loaded only when an error is thrown.

But why was mine not set? Well, the definition is NODE_ENV !== "production".

The issue was with my test environment, bundlejs where the flag was not set properly. Although I am not sure why this happens as the NODE_ENV seems to be defined. I eventually switched to a Vite build and it worked as expected.

Conclusion

Web3js 2.0 has improved things a lot. I appreciate the care taken to minimise bundle size. The new API allows more composability especially around transaction building.