Decode Solana Transactions on a budget

Creating a 45x smaller minimal library to decode Solana transactions in javascript.

Background

Technologies used by Solana

Use case: On the frontend we want to parse Jupiter Swap Events, you can see this when viewing a transaction on Solscan.

const tx = getTransaction(txHash)
const swaps = parseTxSwaps(tx) // Imaginary function
[
{
amm: "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo",
inputMint: "MEW1gQWJ3nEXg2qgERiKu7FAFj79PHvQVREQUzScPP5",
inputAmount: 341209n,
outputMint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
outputAmount: 14667n,
}
]f

Even using getParsedTransaction from web3.js you will notice that the data is in its raw, base58 encoded form.

rpc.getParsedTransaction(txHash)
// Jupiter
{
"accounts": ["D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf"],
"data": "QMqFu4fYGGeUEysFnenhAvBobXTzswhLdvQq6s8axxcbKUPRksm2543pJNNNHVd1VJ58FCg7NVh9cMuPYiMKNyfUpUXSDci9arMkqVwgC1zp94XrEkgEX68QGBNbfpkzGSTG2i4ReApCRe6qocBT275xZsK54Z8h8GxZS4WWsSd6AvK",
"programId": "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4",
"stackHeight": 2
},
// Some programs, such as SPL are decoded
{
"parsed": {
"info": {
"authority": "HLujcj6D7kdH3gLktJRXB95vRbfhp558HvsFoSLMKaSZ",
"destination": "AhR2gTxbKpouGEzTJ86Cki2z2qSDe9p7to8jxYCAWsfZ",
"mint": "MEW1gQWJ3nEXg2qgERiKu7FAFj79PHvQVREQUzScPP5",
"source": "CGb9s5dyTqJuXRKwJkvWmVkLPzy3B9iYYksdn2Q7nGLb",
"tokenAmount": { "amount": "341209", "decimals": 5, "uiAmount": 3.41209, "uiAmountString": "3.41209" }
},
"type": "transferChecked"
},
"program": "spl-token",
"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
"stackHeight": 3
},

Typescript Ecosystem

Conveniently Jupiter provides a package to parse events.

import { extract } from "@jup-ag/instruction-parser"
extract(tx)

The cost

Jupiter Instruction Parser dependency Graph

The dependency graph is huge.

Specifically for parsing these are the main dependencies.

@jup-ag/instruction-parser@coral-xyz/anchor@coral-xyz/borshbuffer-layout

We could go down a layer and use @coral-xyz/anchor, which allows you to use an IDL to parse, but it is still large.

Issues

Minimal parser for Swap Events

Our scope is limited, we only need to decode and for 1 type of event, Jupiter’s Swap event. With this lets figure out the minimal steps required.

  1. Get data for swap transaction 2a4EpB...
const tx = await getTransaction("2a4EpB...")
tx.meta.innerInstructions.forEach(ix => {...})

We filter out Jupiter Program ID JUP6Lkb... instruction data

ix {
accounts: [ 'D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf' ],
data: 'QMqFu4fYGGeUEysFnenhAvBobXTzswhLdvQq6s8axxcbKUPRksm2543pJNNNHVd1VJ58FCg7NVh9cMuPYiMKNyfUpUXSDci9arMkqVwgC1zp94XrEkgEX68QGBNbfpkzGSTG2i4ReApCRe6qocBT275xZsK54Z8h8GxZS4WWsSd6AvK',
programId: 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4',
stackHeight: 2
}
ix {
accounts: [ 'D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf' ],
data: 'QMqFu4fYGGeUEysFnenhAvBobXTzswhLdvQq6s8axxcbKUPRksm2543pJNNNHVd1VJ4E2hRWa3GsBQPU2sRp3sRtjPENk4z91Q3X1PbK516ePc2y6ByX88EtCjDWkqotkzT2RmM7oWpZpVXPJqk9N7YoG7hjSjejznGCKmaoH7u68dM',
programId: 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4',
stackHeight: 2
}
ix {
accounts: [ 'D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf' ],
data: 'QMqFu4fYGGeUEysFnenhAvDWgqp1W7DbrMv3z8JcyrP4Bu3Yyyj7irLW76wEzMiFqkMXcsUXJG1WLwjdCWzNTL6957kdfWSD7SPFG2av5YHKd5MazCGSGzUpJNtxRdjzMQ124wR1QyZj2zDKLPDXmi2Q4WgHVPnzBgFHQNvvw93wLk7',
programId: 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4',
stackHeight: 2
}
  1. Decode from base58
const ixData = base58.decode(ix.data)

On Solscan the Instruction Data Raw is displayed in hex by default, doing that will make it easier for us to visualise the binary structure.

base16.encode(ixData).toLowerCase()
// Hex
e4 45 a5 2e 51 cb 9a 1d 40 c6 cd e8 26 08 71 e2
04 e9 e1 2f bc 84 e8 26 c9 32 cc e9 e2 64 0c ce
15 59 0c 1c 62 73 b0 92 57 08 ba 3b 85 20 b0 bc
06 9b 88 57 fe ab 81 84 fb 68 7f 63 46 18 c0 35
da c4 39 dc 1a eb 3b 55 98 a0 f0 00 00 00 00 01
a0 86 01 00 00 00 00 00 05 2e e1 83 38 96 96 9f
8c d1 cd 46 83 18 c5 98 c7 e0 58 96 07 4a 59 1c
2a e0 98 60 2f 16 80 00 d9 34 05 00 00 00 00 00
  1. The first 8 bytes is the instruction discriminator, an identifier. We can skip this as we only care are about the Swap Events emitted (source)
// Skip
// e4 45 a5 2e 51 cb 9a 1d : `global:${instruction_name}` e.g. `global:swap`
const eventData = ixData.subarray(8)
  1. This leaves us with event data. Firstly, we need to determine the event type to deserialize as there may be multiple possible events.
.. .. .. .. .. .. .. .. 40 c6 cd e8 26 08 71 e2
04 e9 e1 2f bc 84 e8 26 c9 32 cc e9 e2 64 0c ce
15 59 0c 1c 62 73 b0 92 57 08 ba 3b 85 20 b0 bc
06 9b 88 57 fe ab 81 84 fb 68 7f 63 46 18 c0 35
da c4 39 dc 1a eb 3b 55 98 a0 f0 00 00 00 00 01
a0 86 01 00 00 00 00 00 05 2e e1 83 38 96 96 9f
8c d1 cd 46 83 18 c5 98 c7 e0 58 96 07 4a 59 1c
2a e0 98 60 2f 16 80 00 d9 34 05 00 00 00 00 00

To identify events, there is an event discriminator in the first 8 bytes. This comprises of the first 8 bytes from the sha256 hash of the event signature. The signature is event:${event_name} (source). You can try this out on CyberChef.

// namespace = "event"
// event name from IDL = "SwapEvent"
const signature = `${namespace}:${name}`
// 40 c6 cd e8 26 08 71 e2
sha256(signature).subarray(0, 8)

Now we can identify Swap Events and deserialize with the appropriate struct.

  1. Deserializing the data

From Jupiter’s Program IDL we can determine the struct. The struct is quite simple. No need for an IDL parser, we can declare this in code directly.

"events": [
{
"name": "SwapEvent",
"fields": [
{ "name": "amm", "type": "publicKey", "index": false },
{ "name": "inputMint", "type": "publicKey", "index": false },
{ "name": "inputAmount", "type": "u64", "index": false },
{ "name": "outputMint", "type": "publicKey", "index": false },
{ "name": "outputAmount", "type": "u64", "index": false }
]
}
],
Anatomy of the Solana transaction instruction data

With this struct in mind, we can write deserializers for the specific data types and combine them to parse the event based off the structure.

We can use modern javascript functionality to do this without bringing in large dependencies.

Update 19 Oct. Web3js v2 has landed and exports decoders that we can use. I highly recommend you use that instead.

  1. Deserialize public key - 32 bytes types
  2. Deserialize little endian uint64- 8 bytes type

Combining both, we can now succesfully deserialize Swap Event with only minimal code and dependencies.

Comparisons

The impact of this is significant.

Notable mentions

These are good references, which were useful for de/serialization implementations

Conclusion

Unsure if we will run into issues with more complex data types or scenarios. I made an experimental library AnchorES with a simple API and an easy way to extend or provide new structs to decode. The struct declaration is inspired by validation libraries.