Photo by GuerrillaBuzz on Unsplash
If you’re coming from the EVM world, decoding Solana transactions can feel like learning a new language. In Ethereum, you’d use ABIs and tools like ethers.js to decode transaction data. In Solana, the process is different — but once you understand it, it’s just as powerful.
This guide is for:
• EVM developers moving to Solana
• Backend engineers building indexers, bridges, analytics platforms, …
• Anyone decoding on-chain Solana data
I’ll walk you through decoding Solana transaction data using a real-world example: an on-chain auction program (i.e., smart contract). We’ll use the Solana Transaction Parser library from Debridge Finance and learn how to handle custom program instructions that aren’t decoded by default.
Source code: https://github.com/AndreAugusto11/example-usage-solana-parser
Before we dive in, clone the repository and set up your environment:
git clone https://github.com/AndreAugusto11/example-usage-solana-parser.git
cd example-usage-solana-parser
npm install
You’ll also need TypeScript tooling:
npm install -D typescript ts-node
Requirements:
Before we dive into code, let’s understand how transaction decoding differs between these two ecosystems.
In Ethereum and other EVM chains, transaction decoding relies on:
ABIs (Application Binary Interfaces): JSON files that describe contract interfaces, including function signatures, parameter types, and return values. When you call a contract function, your transaction data includes:
0x23b872dd // Function selector (first 4 bytes of keccak256 hash)
0000000... // Encoded parameters (ABI-encoded)
Function selectors: The first 4 bytes of the keccak256 hash of the function signature (e.g., transfer(address,uint256)). Tools like ethers.js and web3.js use the ABI to automatically decode these transactions.
The workflow is straightforward:
Solana’s architecture is fundamentally different. Programs are stateless and transactions can call multiple programs in sequence. This requires a different decoding approach:
IDLs (Interface Description Language): Similar to ABIs, but specific to the Anchor framework (the most popular Solana development framework). An IDL describes:
Discriminators: 8-byte identifiers (not 4 bytes like EVM) derived from the sha256 hash of a namespace and name. For instructions, this is global:instruction_name. For accounts, it's account:account_name.
c7385526 92f3259e // 8-byte discriminator (first 8 bytes of sha256)
0000000... // Borsh-serialized parameters
The key differences:
Comparison between EVM and SolanaIn the EVM world, most tools come with built-in ABI decoding. Pass in an ABI, and you’re done. In Solana, while the Anchor framework provides IDLs, you often need to:
This might seem more complex, but it gives you fine-grained control over transaction parsing and a deeper understanding of what’s happening on-chain.
In this tutorial, we’ll decode transactions from Mayan Swift’s auction program (Mayan Swift is a fast bridge between multiple blockchains, serving as a very important piece in the Solana ecosystem, https://docs.mayan.finance/architecture/swift). You’ll learn to:
By the end, you’ll be able to decode any Anchor-based Solana program’s transactions.
Now let’s see this in action. We want to decode data from Mayan Swift’s auction program (9w1D9okTM8xNE7Ntb7LpaAaoLc6LfU9nHFs2h2KTpX1H).
Here’s our starting point — a transaction hash from a bid instruction: Ahy9GEyiPzkrw54Js6rw43bD6m6V3zmDDK6nn6e8N2tskrbkiozhsMjcdBLvCgH5JAc8CFyUZiwWpyCNqQ4wmQb.Feel free to choose another from https://solscan.io/account/9w1D9okTM8xNE7Ntb7LpaAaoLc6LfU9nHFs2h2KTpX1H
Let’s try to parse it without any custom decoders. Create a file src/no-custom-decoder.ts:
import { Connection, clusterApiUrl } from "@solana/web3.js";
import { SolanaParser } from "@debridge-finance/solana-transaction-parser";
const rpcConnection = new Connection(clusterApiUrl("mainnet-beta"));
const txParser = new SolanaParser([]);
const parsed = await txParser.parseTransactionByHash(
rpcConnection,
"Ahy9GEyiPzkrw54Js6rw43bD6m6V3zmDDK6nn6e8N2tskrbkiozhsMjcdBLvCgH5JAc8CFyUZiwWpyCNqQ4wmQb"
);
console.log(JSON.stringify(parsed, null, 2));
Run it with:
npx ts-node src/no-custom-decoder.ts
The parser successfully decodes some instructions automatically — the compute budget instructions and system program calls are recognized because they’re common programs already loaded into the parser by default:
[
{
name: 'setComputeUnitLimit',
accounts: [],
args: { units: 74200 },
programId: 'ComputeBudget111111111111111111111111111111'
},
{
name: 'setComputeUnitPrice',
accounts: [],
args: { microLamports: 60000 },
programId: 'ComputeBudget111111111111111111111111111111'
},
// ... but then we hit our auction instruction:
{
programId: '9w1D9okTM8xNE7Ntb7LpaAaoLc6LfU9nHFs2h2KTpX1H',
accounts: [ ... ],
args: {
unknown: <Buffer c7 38 55 26 92 f3 25 9e 00 00 00 00 00 00 00 00 ...>
},
name: 'unknown'
}
]
That unknown Buffer is your encoded instruction data—255 bytes of Borsh-serialized parameters that the parser can't decode without the program's IDL. This is where our work begins.
First, fetch the IDL for your program. For Anchor programs, you can find this on Solscan:
Solscan: https://solscan.io/account/9w1D9okTM8xNE7Ntb7LpaAaoLc6LfU9nHFs2h2KTpX1H#anchorProgramIdl
Save the JSON file to src/idl/swift_auction.json.
Install the Anchor IDL to TypeScript converter, such that the tool can parse its data types:
cargo install simple-anchor-idl-ts
Run the converter:
simple-anchor-idl-ts src/idl/swift_auction.json
This creates src/idl/swift_auction.ts with TypeScript type definitions.
The IDL exported from Solscan is incomplete and won’t work with Anchor’s TypeScript client. We need to fix three critical issues to make it compliant with the Anchor IDL specification. Think of this as the difference between a draft ABI and a production-ready one.
The Anchor IDL specification requires these fields at the root level, but Solscan’s export omits them. Without these, the Anchor TypeScript client won’t recognize the IDL structure.
Why it matters: The address field ties the IDL to the specific on-chain program, while metadata provides versioning and spec information that tools use for compatibility checks.
At the top of your IDL type definition, add:
export type SwiftAuctionIDLType = {
"version": "0.1.0",
"name": "swift_auction",
"address": "9w1D9okTM8xNE7Ntb7LpaAaoLc6LfU9nHFs2h2KTpX1H", // Add this
"metadata": { // Add this
"name": "Swift Auction",
"version": "0.1.0",
"spec": ""
},
"instructions": [
// ...
]
}
Don’t forget to update the corresponding SwiftAuctionIDL object (around line 408) with the same fields.
This is the critical part. Remember those 8-byte discriminators we discussed earlier? They’re how Solana programs route instructions and identify account types. Without them, we can’t match instruction data to the correct decoder function.
Why it matters: When a transaction comes in with data starting with c7 38 55 26 92 f3 25 9e, we need to know that it maps to the bid instruction. Discriminators make this possible.
Create a helper script calculate-discriminator.js:
const crypto = require('crypto');
const name = process.argv[2];
if (!name) {
console.log('Usage: node calculate-discriminator.js <namespace:name>');
process.exit(1);
}
const hash = crypto.createHash('sha256').update(name).digest();
const discriminator = Array.from(hash.slice(0, 8));
console.log(`Discriminator: "discriminator": [${discriminator.join(', ')}]`);
Now calculate discriminators for each instruction using the global: namespace:
node calculate-discriminator.js global:bid
# Output: "discriminator": [199, 56, 85, 38, 146, 243, 37, 158]
node calculate-discriminator.js global:postAuction
# Output: "discriminator": [123, 68, 53, 83, 90, 211, 160, 63]
node calculate-discriminator.js global:closeAuction
# Output: "discriminator": [214, 18, 110, 197, 2, 7, 153, 74]
node calculate-discriminator.js global:updateConfig
# Output: "discriminator": [102, 77, 158, 235, 28, 216, 191, 121]
And for accounts using the account: namespace:
node calculate-discriminator.js account:auctionState
# Output: "discriminator": [110, 220, 44, 10, 38, 234, 132, 215]
node calculate-discriminator.js account:auctionTime
# Output: "discriminator": [173, 208, 94, 49, 209, 242, 0, 214]
Add these discriminators to each instruction and account definition in your IDL file. For example:
{
"name": "bid",
"discriminator": [199, 56, 85, 38, 146, 243, 37, 158], // Add this array
"accounts": [ ... ],
"args": [ ... ]
}
Pro tip: Notice how the first bytes of our unknown buffer (c7 38 55 26...) match the bid discriminator? That's how we'll route it to the right decoder!
The IDL converter sometimes generates incorrect type references. The Anchor specification expects a specific format for custom type definitions.
Why it matters: Without the correct format, the TypeScript types won’t compile, and your decoder won’t work.
Find any instances of:
"defined": "OrderInfo"
And change them to:
"defined": {
"generics": [],
"name": "OrderInfo"
}
This matches the Anchor IDL specification for type references and ensures proper TypeScript type generation.
After these fixes, your IDL should be fully Anchor-compliant. You can verify this by checking that:
Now we’re ready to build the actual decoder!
Now comes the exciting part: transforming raw bytes into meaningful data. We’ll build a decoder that mimics how Anchor programs read instruction data on-chain.
When a Solana program receives instruction data, it follows this pattern:
Our decoder will do the same thing, but in TypeScript rather than Rust.
Create src/custom-parsers/swift-auction-parser.ts:
import { TransactionInstruction } from "@solana/web3.js";
import { SwiftAuctionIDL, SwiftAuctionIDLType } from "../idl/swift_auction";
import {
ParsedIdlInstruction,
ParsedInstruction
} from "@debridge-finance/solana-transaction-parser";
import { decodeOrderInfo } from "../decodeOrderInfo";
import BN from "bn.js";
// Extract function selectors (first byte of each discriminator)
// This creates a lookup table: { "bid": 199, "postAuction": 123, ... }
const FUNCTION_SELECTORS = SwiftAuctionIDL["instructions"].reduce((acc, i) => {
acc[i.name] = i.discriminator[0];
return acc;
}, {} as Record<string, number>);
function decodeSwiftAuctionInstruction(
instruction: TransactionInstruction
): ParsedInstruction<SwiftAuctionIDLType> {
// The first byte tells us which instruction this is
const instruction_selector = instruction.data[0];
// Everything after byte 0 is the Borsh-serialized arguments
const remaining_data = instruction.data.slice(1);
// Route to the appropriate decoder based on the selector
switch (instruction_selector) {
case FUNCTION_SELECTORS["bid"]:
return decodeBidInstruction(instruction, remaining_data);
case FUNCTION_SELECTORS["postAuction"]:
// You would implement this similarly
throw new Error("postAuction decoder not yet implemented");
// Add more cases for other instructions as needed
default:
throw new Error(`Unknown instruction selector: ${instruction_selector}`);
}
}
export { decodeSwiftAuctionInstruction };
What’s happening here:
Now let’s implement the decoder for the bid instruction:
function decodeBidInstruction(
instruction: TransactionInstruction,
data: Buffer
): ParsedIdlInstruction<SwiftAuctionIDLType, "bid"> {
return {
name: "bid",
programId: instruction.programId,
// Map instruction accounts to their semantic names
// The order matches the IDL's account definition
accounts: [
{ name: "config", pubkey: instruction.keys[0].pubkey },
{ name: "driver", pubkey: instruction.keys[1].pubkey },
{ name: "auctionState", pubkey: instruction.keys[2].pubkey },
{ name: "systemProgram", pubkey: instruction.keys[3].pubkey },
],
// Decode the arguments according to the IDL structure
args: {
// The 'order' field is a complex struct defined in the IDL
// We need a custom decoder for it (see below)
order: decodeOrderInfo(data),
// The 'amountBid' field is a u64 at the end of the buffer
// Read it as a big-endian 64-bit unsigned integer
amountBid: new BN(data.readBigUInt64LE(data.length - 8)),
}
} as ParsedIdlInstruction<SwiftAuctionIDLType, "bid">;
}
Key points:
The OrderInfo struct contains multiple fields with different types. You'll need to create src/decodeOrderInfo.ts to handle this:
import BN from "bn.js";
export function decodeOrderInfo(data: Buffer) {
let offset = 0;
// Read each field according to the IDL type definition
// Borsh serializes fields sequentially in declaration order
const trader = Array.from(data.slice(offset, offset + 32));
offset += 32;
const chainSource = data.readUInt16LE(offset);
offset += 2;
const tokenIn = Array.from(data.slice(offset, offset + 32));
offset += 32;
const addrDest = Array.from(data.slice(offset, offset + 32));
offset += 32;
const chainDest = data.readUInt16LE(offset);
offset += 2;
const tokenOut = Array.from(data.slice(offset, offset + 32));
offset += 32;
// Continue for all fields defined in the OrderInfo struct...
const amountOutMin = new BN(data.slice(offset, offset + 8), 'le');
offset += 8;
// ... and so on for the remaining fields
return {
trader,
chainSource,
tokenIn,
addrDest,
chainDest,
tokenOut,
amountOutMin,
// ... other fields
};
}
The pattern:
This is exactly how Borsh deserialization works — sequential, fixed-layout reads.
Finally, register your custom decoder with the Solana Transaction Parser. Create src/auction-custom-decoder.ts:
import { Connection, clusterApiUrl } from "@solana/web3.js";
import { SolanaParser } from "@debridge-finance/solana-transaction-parser";
import { decodeSwiftAuctionInstruction } from "./custom-parsers/swift-auction-parser";
const SWIFT_AUCTION_PROGRAM_ID = "9w1D9okTM8xNE7Ntb7LpaAaoLc6LfU9nHFs2h2KTpX1H";
const rpcConnection = new Connection(clusterApiUrl("mainnet-beta"));
// Create parser with custom decoder
const txParser = new SolanaParser([
{
programId: SWIFT_AUCTION_PROGRAM_ID,
instructionParser: decodeSwiftAuctionInstruction,
}
]);
// Parse the same transaction we tried earlier
const parsed = await txParser.parseTransactionByHash(
rpcConnection,
"Ahy9GEyiPzkrw54Js6rw43bD6m6V3zmDDK6nn6e8N2tskrbkiozhsMjcdBLvCgH5JAc8CFyUZiwWpyCNqQ4wmQb"
);
console.log(JSON.stringify(parsed, null, 2));
Run it with:
npx ts-node src/auction-custom-decoder.ts
The parser will now use your custom decoder whenever it encounters an instruction from the Swift Auction program!
To see the difference between parsed and unparsed transactions:
Without custom decoder:
npx ts-node src/no-custom-decoder.ts
With custom decoder:
npx ts-node src/auction-custom-decoder.ts
The second command will show the fully decoded bid instruction with all its parameters visible and readable.
Let’s compare what we had before and after implementing our custom decoder.
{
programId: '9w1D9okTM8xNE7Ntb7LpaAaoLc6LfU9nHFs2h2KTpX1H',
accounts: [ ... ],
args: {
unknown: <Buffer c7 38 55 26 92 f3 25 9e 00 00 00 00 00 00 00 00 ...>
},
name: 'unknown'
}
{
name: 'bid',
programId: '9w1D9okTM8xNE7Ntb7LpaAaoLc6LfU9nHFs2h2KTpX1H',
accounts: [
{ name: 'config', pubkey: '93boUvm9QnkHTa5sUGMuFegLaxYsJQVMrNCAz7HojnY5' },
{ name: 'driver', pubkey: 'B88xH3Jmhq4WEaiRno2mYmsxV35MmgSY45ZmQnbL8yft' },
{ name: 'auctionState', pubkey: 'Cq8nomBLmD4LwrYBA4J3Wk6C4GtcRT72Nb5e1RJcdk8C' },
{ name: 'systemProgram', pubkey: '11111111111111111111111111111111' }
],
args: {
order: {
trader: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 90, 122, 111, ...],
chainSource: 30,
tokenIn: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 53, 137, 252, ...],
addrDest: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 90, 122, 111, ...],
chainDest: 4,
tokenOut: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 85, 211, 152, 50, ...],
amountOutMin: <BN: 39ccb90fc0>, // 250,000,000,000 (250 USDC)
gasDrop: <BN: 0>,
feeCancel: <BN: 1edf>, // 7,903 lamports
feeRefund: <BN: 45e>, // 1,118 lamports
deadline: <BN: 685e7892>, // Unix timestamp
addrRef: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 165, 170, 110, 33, ...],
feeRateRef: 0,
feeRateMayan: 3, // 0.03% fee
auctionMode: 2, // Dutch auction
keyRnd: [74, 172, 120, 171, 220, 249, 5, 206, ...] // Random key
},
amountBid: <BN: 3a1d331485> // 250,123,456,645 lamports
}
}
Now we can see exactly what this transaction does:
2. The minimum output amount is 250 USDC
3. The bid amount is ~250.12 SOL
4. It’s a Dutch auction (mode 2) with a 0.03% Mayan fee
5. Includes deadline, fee structures, and referral information
Remember our theoretical comparison at the beginning? We’ve successfully:
If you’re transitioning from Ethereum to Solana, here are the essential mindset shifts:
EVM: ABIs are universally supported. Pass one to ethers.js and you’re done.
Solana: IDLs are Anchor-specific and require more manual setup. But this gives you visibility into exactly how data flows through your programs.
EVM: 4-byte function selectors from keccak256(functionSignature).slice(0,4)
Solana: 8-byte discriminators from sha256(namespace:name).slice(0,8)
The extra bytes reduce collision risk (which has been used in the past in contract exploits) and support namespacing for instructions, accounts, and events.
EVM ecosystem:
const contract = new ethers.Contract(address, abi, provider);
const tx = await contract.populateTransaction.transfer(to, amount);
// Everything decoded automatically
Solana ecosystem:
const txParser = new SolanaParser([customDecoder]);
const parsed = await txParser.parseTransactionByHash(connection, hash);
// Custom decoders required for new programs
The Solana approach requires more initial work but teaches you the internals of program communication.
Now that you can decode transaction data, you can:
The full source code is available on GitHub: https://github.com/AndreAugusto11/example-usage-solana-parser
Additional Resources:
Welcome to Solana! 🚀
Found this helpful? Follow me for more tutorials and cross-chain insights.
How to Decode Solana Transaction Data (and how it differs from EVM) was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.


