Decoding Custom DEX Events with Dune Analytics

Most order-book DEXs don't emit clean, well-labeled events. You get a raw log with a topic0 hash, a couple of indexed topics, and a data blob that looks like noise unless you know exactly how it was packed. Dune's decoded tables save you from this — until the contract you're tracking isn't decoded, or the decoder mangles a custom event type. Then you're back to raw logs and SQL, doing the ABI's job by hand.
This is the pattern I use every time a new DEX shows up without a working decoder.
The problem: raw logs, no ABI
Every EVM log has this shape:
topic0: the keccak256 hash of the event signature (this identifies which event fired)
topic1, topic2, topic3: indexed parameters, each 32 bytes
data: the non-indexed parameters, packed sequentially, 32 bytes per slot (with left-padding for anything shorter than 32 bytes, like an address)
If Dune hasn't decoded the contract, xxx.logs-style raw log tables give you exactly this — and nothing else. You have to reconstruct meaning from position.
Step 1: identify the event by topic0
Every event signature has a fixed hash. For an order-placement event like:
`
event OrderPlaced(address indexed owner, uint256 orderId, uint256 amount);
`
`topic0` is `keccak256("OrderPlaced(address,uint256,uint256)")`. You can compute this yourself (Python's `web3.py` has a one-liner for it), or reverse-engineer it by grabbing a transaction you know contains the event and checking which topic0 shows up in the raw logs at the block/index you expect.
Once you have it, every log row that matters starts with a `WHERE topic0 = 0x...` filter. This is the anchor for the entire query — get it wrong and you'll either return zero rows or, worse, silently mix in an unrelated event that happens to share slot conventions.
`
SELECT *
FROM xxx.logs
WHERE contract_address = 0x<dex_contract>
AND topic0 = 0x<order_placed_signature_hash>
`
## Step 2: pull indexed params straight from topics
If `owner` is `indexed`, it lives in `topic1` as a full 32-byte word, left-padded. Addresses are 20 bytes, so you slice off the last 20:
\`\`\`sql
bytearray\_substring(topic1, 13, 20) AS owner\_address
\`\`\`
Trino's `bytearray_substring` is 1-indexed and takes `(binary, start, length)`. A 32-byte word has the address in bytes 13–32, hence `start = 13, length = 20`.
Step 3: pull non-indexed params from data
Anything not indexed goes into `data`, packed in declaration order, 32 bytes per field, regardless of the field's actual size. So for our example event, `data` contains `orderId` (32 bytes) followed by `amount` (32 bytes):
\`\`\`sql
bytearray\_substring(data, 1, 32) AS order\_id\_raw,
bytearray\_substring(data, 33, 32) AS amount\_raw
\`\`\`
Cast the raw bytes to a usable integer with `bytearray_to_uint256` (or your Dune environment's equivalent) after slicing.
The offset math that trips people up: if an address is packed into `data` instead of being indexed, it's still stored as a full 32-byte word — so extracting it is `bytearray_substring(data, <slot_start>+13, 20)`, not `+1`. Treat every `data` field as 32 bytes wide first, then apply the address-specific 13-byte offset within that slot. This is the single most common source of garbage addresses in custom decodes.
Step 4: crediting multiple parties from one event
Fill/match events are the trickier case, because they usually need to credit two sides of a trade — a taker and a maker — from one log:
`
event OrderFilled(address indexed taker, address indexed maker, uint256 amount, uint256 price);
`
Here both addresses are indexed `topic1` and `topic2`), so there's no `data`\-slicing needed for them — but you do need two rows out of one log if you're building a per-user activity table:
`
SELECT
bytearray_substring(topic1, 13, 20) AS user_address,
'taker' AS role,
bytearray_substring(data, 1, 32) AS amount_raw
FROM xxx.logs
WHERE topic0 = 0x<order_filled_signature_hash>
UNION ALL
SELECT
bytearray_substring(topic2, 13, 20) AS user_address,
'maker' AS role,
bytearray_substring(data, 1, 32) AS amount_raw
FROM .logs
WHERE topic0 = 0x<order_filled_signature_hash>
`
This `UNION ALL` pattern is the difference between a volume table that only counts half your trading activity and one that counts all of it. If you're building trader leaderboards, retention cohorts, or volume-based rewards off of fill events, and you only pull `topic1`, you will systematically undercount every maker on the platform — and nobody will notice until the numbers look inexplicably low against what the frontend shows.
Why this matters beyond one contract
This isn't really about one DEX. Any custom on-chain program — permissions registries, staking contracts, bespoke reward systems — follows the same three-step decode: identify by `topic0`, slice indexed topics for fixed-width fields like addresses, slice `data` sequentially for everything else. Once you've done it once, decoding a new undocumented contract is a 15-minute job instead of a guessing game.
The failure mode to watch for isn't the SQL — it's assuming a decoder did this correctly for you. Always spot-check a handful of decoded addresses against a block explorer before trusting a table you didn't build the decode logic for yourself.
\---
Have a custom event you're stuck decoding? The pattern above covers 90% of order-book and AMM event types — the remaining 10% is usually dynamic-length fields (strings, arrays), which is a follow-up post on its own.