Some time ago I worked on Kiln's Ledger plugin. The goal is to enable clearsigning of our smart contract user interactions on ledger devices, which is enabled by making a C plugin that runs on the ledger device and is called for our smart contract addresses. The plugin parses the functions called and their parameters, makes some check and displays in a human reviewable way on the device screen the parameters, this way the user can verify what they actually sign with the on device private key.
For a simpler example, here is what that looks like on Nano S for depositIntoStrategy, where the plugin can at least show a concrete amount:

The two main things we want to focus on here is to make sure that i) we parse the functions correctly, even if they have a very complex ABI ii) we check the ABI properly, and make sure the ABI we are decoding is not forged to make our parser believe it's normal while it is actually adding extra parameters to the tx that will be signed.
This parsing flow is following a sequence of calls defined by the Ethereum App and Plugin SDK.
Inside that flow, our plugin first checks the selector and contracts it receives, then parses a stream of 32-byte ABI words, and has to keep enough state to understand where it is in that stream and if it matches the expected parameters structure.
Here is, from the plugin POV what an ABI (streamed in the provide-param() 32 byte chunks stream) looks like.
// Basic ABI
// deposit(uint256 amount)
0x00000001 // selector
00000000 00000000 00000000 00000000 00000000 00000000 00000000 0000002a // amount = 42
// Struct ABI
// setPoint((uint256 x, uint256 y) p)
0x00000002 // selector
00000000 00000000 00000000 00000000 00000000 00000000 00000000 0000002a // p.x = 42
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000007 // p.y = 7
// Arrays and struct arrays ABI hell
// queueWithdrawals((address[] strategies, uint256[] shares, address withdrawer)[] q)
0x0dd8dd02 // selector
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000020 // offset to q[]
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001 // q.length = 1
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000020 // offset to q[0]
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000060 // q[0].strategies offset
00000000 00000000 00000000 00000000 00000000 00000000 00000000 000000a0 // q[0].shares offset
00000000 00000000 00000000 11111111 11111111 11111111 11111111 11111111 // q[0].withdrawer
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001 // strategies.length = 1
00000000 00000000 00000000 22222222 22222222 22222222 22222222 22222222 // strategies[0]
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001 // shares.length = 1
00000000 00000000 00000000 00000000 00000000 00000000 00000000 000004d2 // shares[0] = 1234My goal was to clearsign interaction with EigenLayer smart contracts, and they have two very annoying function signatures in this context:
queueWithdrawals((address[],uint256[],address)[])completeQueuedWithdrawals((address,address,address,uint256,uint32,address[],uint256[])[],address[][],uint256[],bool[])As we saw above, having arrays of arrays, or arrays of structs (which itself has arrays here!) is annoying because it requires a precise recursive parsing AND we must keep tracks of offsets, length and also some parameter values we want to check once we have received the full ABI.
It is annoying to develop yes, but it's even more annoying to find out that we can only have 160 bytes (5 * 32) of memory for this.
Let's deep dive into the clearsiging of the queueWithdrawals function.


note that in this clearsigning flow, we did not display any amounts, because the amounts passed as parameters are shares which usually mean nothing to the user, and of course we cannot compute the real asset amount as we would need a RPC call which is impossible to do in an offline ledger device.
struct QueuedWithdrawalParams {
address[] strategies;
uint256[] shares;
address withdrawer;
}
function queueWithdrawals(
QueuedWithdrawalParams[] calldata queuedWithdrawalParams
) externalIf we flatten the ABI a bit, this is roughly what the parser receives:
// queueWithdrawals((address[] strategies, uint256[] shares, address withdrawer)[] q)
0x0dd8dd02 // selector
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000020 // offset to q[]
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001 // q.length = 1
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000020 // offset to q[0]
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000060 // q[0].strategies offset
00000000 00000000 00000000 00000000 00000000 00000000 00000000 000000a0 // q[0].shares offset
00000000 00000000 00000000 11111111 11111111 11111111 11111111 11111111 // q[0].withdrawer
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001 // strategies.length = 1
00000000 00000000 00000000 22222222 22222222 22222222 22222222 22222222 // strategies[0]
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001 // shares.length = 1
00000000 00000000 00000000 00000000 00000000 00000000 00000000 000004d2 // shares[0] = 1234So the parser basically sees:
The issue we have here lies in step 3, if there are many withdrawals, you get many struct offsets. The straightforward parser thing to do would be:
But as we get only 160 bytes of shared parser context for the whole flow, we cannot sustainably store all the offset (the array size has no defined maximum, it could contain a lot of elements).
Here it felt like a dead end, spent a few hours thinking of how I could gain some space by reducing my parsing storage and trying to do custom information compression. While I was discussing with @mxs during this phase, I had an idea which we agreed would solve this.
More precisely, a rolling keccak checksum of the offsets. I can keep a hash of the state of my offset parsing, and recompute it from the offset we observe while parsing the structs. If at the end of the parsing both offsets match, they are all valid.
So when the parser reads the offsets table, it does not keep:
[132, 388, 644, ...]It only keeps the final checksum of that sequence, ie it computes for each offset h = hash(previous_h + current_offset_value).
So I get the validation properties I want, without needing the whole list.
And this is what the actual in-memory parser context for queueWithdrawals looked like on the Kiln plugin:
typedef struct {
// -- parsing utils
uint16_t queued_withdrawals_count;
uint16_t current_item_count;
// here we can store this offset because it is only one value
uint16_t shares_array_offset;
// we keep a preview hash of the offsets
uint8_t qwithdrawals_offsets_checksum_preview[CX_KECCAK_256_SIZE];
// we compute a new hash with the observed offsets
// at the end this should == the previous one
uint8_t qwithdrawals_offsets_checksum_value[CX_KECCAK_256_SIZE];
// -- display utils
char withdrawer[ADDRESS_STR_LEN];
uint8_t strategies_count;
uint8_t strategies[MAX_DISPLAYABLE_LR_STRATEGIES_COUNT];
} lr_queue_withdrawals_t;The same checksum trick was later reused for completeQueuedWithdrawals, where the nesting gets even worse. But the interesting part is already visible here on queueWithdrawals: you can keep structural guarantees with almost no memory if you are careful about what you actually need to remember and validate.
It's not fancy, it's super small to store (even a compression algo could not lead to this fix size), it's eleguant.
This is something I really love in web3 tech and ZK, we always need to prove state validity and do not require the full data to do it, idk it's just eleguant.
A fun fact was that later EigenLayer decided to do there own plugin with Zellic, and ended up using the same trick, nicely quoting us in the code of the queueWithdrawals parser:
// Idea taken from Kiln pluginWhich was pretty cool to read.
If you want to read the code: