The hash trick behind the EigenLayer Ledger plugin

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:

Nano S depositIntoStrategy screen 0 Nano S depositIntoStrategy screen 1 Nano S depositIntoStrategy screen 2 Nano S depositIntoStrategy screen 3 Nano S depositIntoStrategy screen 4 Nano S depositIntoStrategy screen 5

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.

LedgerLiveEthereumappPluginSDKPluginsetplugin>txtosign>initialize>initcontract()><chunk0>provideparam()><chunk1>provideparam()><chunk2>provideparam()><...>provideparam()><finalize>finalize()><queryscreens>queryui()><signedrejected<
Ledger Ethereum Plugin Flow

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.

text
// 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] = 1234

My 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.

Nano S queueWithdrawals screen 0 Nano S queueWithdrawals screen 1

Nano S queueWithdrawals screen 3 Nano S queueWithdrawals screen 4 Nano S queueWithdrawals screen 5 Nano S queueWithdrawals screen 6

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.

solidity
struct QueuedWithdrawalParams {
    address[] strategies;
    uint256[] shares;
    address withdrawer;
}
 
function queueWithdrawals(
    QueuedWithdrawalParams[] calldata queuedWithdrawalParams
) external

If we flatten the ABI a bit, this is roughly what the parser receives:

text
// 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] = 1234

So the parser basically sees:

  1. an offset to the array
  2. the array length
  3. [Multiple] one offset per struct
  4. [Multiple] the structs themselves

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:

  1. store every offset from the array header
  2. later, when you actually start parsing the structs, verify that each struct begins where the corresponding offset said it should begin. This is to ensure the ABI you receive is valid and not malformed / potentially malicious.

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.

The trick

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:

text
[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:

c
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.

What I like about this approach

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:

c
// Idea taken from Kiln plugin

Which was pretty cool to read.

If you want to read the code: