“Stack Too Deep”- Error in Solidity

Happy New Year and may we all have great accomplishments in 2019! Aventus is welcoming you back from the winter holidays with a post related to errors in Solidity, by our very own, Alex Pinto.


When one starts coding smart contracts in Solidity, sooner or later, s/he will hit a very annoying obstacle. The “Stack Too Deep” error. It is easy to fall into this trap, and when that happens, it is often hard to find a way out. To be fair, the underlying reason is not in Solidity itself, but in the Ethereum Virtual Machine (the EVM), and so will likely affect other languages that compile into EVM (ie LLL, Serpent, Viper), but that is a subtle distinction in the day-to-day job of coding smart contracts.

Surprisingly, given the level of annoyance this can cause, it is very hard to find good resources on how to deal with it, so I decided to write this post to try to shine some light on it, for my own benefit and for anyone else who may be despairing with it.

In general terms, this error seems to be generated when the code needs to access a slot in the stack that is deeper than its 16th element (counting from the top downwards). How we get there, though, can be done in more than one way. This post does not aim to offer a full theory of how this error is generated: from my experience, there are too many ways to do it. But it will give a good rationale for a common trigger, and will hopefully make the reader more aware of how the EVM manages its stack. It may even be possible to extend the same logic to other situation where the error occurs, and look for ways to avoid it.

In Solidity, most types (ie elementary types, like numbers, addresses and booleans for example, but not arrays, structs nor mappings) are passed by value to a function: when the function is called, a part of the stack (ie a stack frame) is allocated to hold the return position the program should go to when the function returns (the ‘return address’) and a copy of the function value-type input and output arguments. Each argument will normally hold a slot in the stack, where each slot is 256 bits.

This provides the most basic way of hitting a “Stack Too Deep” error: have a total of more than 16 input and output arguments. But in reality, if we want that function to do something useful, we will have to be very careful and probably have to reduce the number of arguments.

To test this, I created a small contract in Remix like this:

pragma solidity ^0.4.24;
contract TestStackError {
event LogValue(uint);
function logArg(uint a1) public {
emit LogValue(a1);
}
}

Remix is great for an investigation like this because we can quickly write a contract and query it, but fundamentally because Remix gives a powerful debugger with opcode disassembly and a full listing of the stack, memory and storage. It’s also easy to move back and forward through the code, giving one of the best debugging experiences I’ve had in any language.

This contract is very simple: it has no state variables and only one function, which also is extremely uncomplicated. This function takes only one argument and logs it.

I copy this contract to a new file in Remix, compile it and deploy it. There should be no errors and warnings, and so I go to the Run tab, and hit Deploy.

Then, I extend the list for the SimpleFunction contract, and enter a single value in the box in front of logArg. I press the button and check the output in the console:

As you can see, I entered the value 7, and that was returned as the only element in the logs. Although logs are worth another post, there are a few things I should mention here.

This is the JSON-formatted logs object of this call:

logs [
{
"from": "0xef55bfac4228981e850936aaf042951f7b146e41",
"topic": "0xfcf771399d75a67a6d0e730ae98d34c40b6bfe6ebf8053b98ddf4da8c2706250",
"event": "LogValue",
"args": {
"0": "7",
"length": 1
}
}
  • Logs are created by the emit keyword in solidity, which raise a solidity event and correspond to LOGn opcodes.
  • Logs can be filtered by client-side applications running off-chain. A filter is a condition on any of the topics available in the log.
  • A log always has a topic 0, which is an encoding of the event’s signature.
  • Further topics can be created by making an argument indexed. There can be up to 3 indexed arguments. The remaining ones are considered event data

In this simple example, we can easily identify that there is only one topic ("0xfcf771399d75a67a6d0e730ae98d34c40b6bfe6ebf8053b98ddf4da8c2706250") and that the data are displayed as part of the args member of the log object. We can also verify the code is working as expected.

Let’s now test the limits of this contract and change the function to accept the maximum number of arguments.

pragma solidity ^0.4.24;
contract TestStackError {
event LogValue(uint);
function logArg(uint a1, uint a2, uint a3, uint a4,
uint a5, uint a6, uint a7, uint a8,
uint a9, uint a10, uint a11, uint a12,
uint a13, uint a14, uint a15, uint a16
) public {
emit LogValue(a16);
}
}

I have 16 input variables, no output variables, therefore I only need to use 16 stack slots. I invoke the function passing the values 1 to 16 and emit the last value. I check the logs and see the value 16. Brilliant, this works!

Then, I make a very small change to my contract: I log the first argument instead:

Wait, what?! Simply logging a different argument has turned a perfectly fine contract into a “Stack Too Deep” error. Wow, what’s going on in here?

This is not something Solidity can elucidate. At that level, the change looks perfectly harmless. I need to go down into the EVM bytecode to understand what is going on. But before I do that, I want to make another test, to gather some clues. I create a third version of this contract, but logging a2 instead:

pragma solidity ^0.4.24;
contract TestStackError {
event LogValue(uint);
function logArg(uint a1, uint a2, uint a3, uint a4,
uint a5, uint a6, uint a7, uint a8,
uint a9, uint a10, uint a11, uint a12,
uint a13, uint a14, uint a15, uint a16
) public {
emit LogValue(a2);
}
}

This works, and logs the correct value. The same happens when I log a3. I hypothesise then that all the arguments between a2 and a16 can correctly be logged.
The resulting opcodes are in these three files:

log(a2)

log(a3)

log(a16)

I compared all the 3 logs between themselves, and the very first thing that struck me was that they all differed in size (number of lines). The second thing is that they are remarkably equal until line 237, with only one exception. The code after this line is very different and apparently unpredictable. However, since that seems to come after the function has returned, I will simply ignore it.

Then I focused on the one difference between line 237, occurring at line 198. I was happy to confirm an idea that I had thought could explain the stack too deep error — that in some place of the code we would logically need to call some non-existing DUP or SWAP opcode. That indeed seems to be the case here: all 3 versions are the same until line 237, except for one single difference on line 198:

  • log(a2): DUP16
  • log(a3): DUP15
  • log(a16): DUP2

The opcodes DUPn duplicate the value at the nth level of the stack. There are only 16 such opcodes, from DUP1 to DUP16. DUP1 pushes to the stack a copy of the value currently at the top, and DUP16 copies the 16th highest value in the stack. There is an evident relationship between the place of the variable in the argument list and the value of DUPn in this line, and if I extrapolate it to the case log(a1), this rule implies we will need an opcode DUP17. But such an opcode does not exist, it points to a value lower in the stack than we can reach, which justifies the error message “Stack Too Deep”.

Satisfied with this, my natural curiosity asks the question: what role is this DUP opcode performing here? What is its purpose?

Bytecode is intimidating. The last time I looked at assembly code with some level of intention of understanding it was in my teens, playing with the Spectrum’s Z80 processor. I have not any experience of doing it with the EVM, so I don’t plan to parse 200 lines of an assembly-like listing in my head. But Remix does offer quite good tools in this respect. In the debug tab, we can replay the transaction opcode by opcode, and at a glance see the contents of the stack, the memory and the storage, among others.

Before I proceed, I’d like to point you towards this series of posts in the Zeppelin blog by Alejandro Santander on the structure of assembly EVM code. It is a priceless introduction to EVM assembly, and will save me from having to explain the boilerplate. Another extremely useful link is this list of EVM opcodes, that is my favourite reference to find the functionality of each opcode. I highly recommend it.

There is not much to this function, and most of the bytecode is repetitive. There are 17 occurrences of the opcode CALLDATALOAD. The first one appears in the first block of the code, before the function dispatch. It checks whether the calldata is too short (line 12), in which case the function would revert. After this, it compares the function selector to those of the methods known to the contract (in this case, only one: e898288f) and if it matches any, directs the flow to the address that implements that function. Otherwise, the call reverts.

In this case, the code has called the only existing function and so the flow jumps to address 70 (line 25) to process it.

The remaining 16 instances of CALLDATALOAD are exactly the number or arguments we have, they appear at exactly 9 lines intervals, and are probably responsible for processing each argument to the function. So, I ran over these lines with the Remix debugger and observed that they do load each successive argument onto the stack (I’m not worried with how exactly those 9 opcodes copy these data). These are followed by 3 POP instructions that clear the part of the stack we no longer need (which was used to calculate the position in the call data of the next argument to be read). At this point, the top of the stack holds the 16th argument, the second element holds the 15th argument and so on. The 16th element of the stack is, at this stage, the first argument. This is followed by the return address of the function (0x109) and the function selector.

The code then pushes into the stack the 32 byte identifier of topic 0 fcf771399d75a67a6d0e730ae98d34c40b6bfe6ebf8053b98ddf4da8c2706250, which pushes the first input out of the top 16 elements of the stack, and follows this with the DUP opcode that puts at the top of the stack the argument for the log event (eg a2 or a16).

The next 20 lines or so prepare the memory to hold the argument of the log event at memory position 0x80, and guarantee the stack has in its top two positions this address and the length of the data (0x20). Then, it calls the opcode LOG1, which emits a log event with one single argument and one topic, using the data at the top 3 positions in the stack:

  • 0: 0x0000000000000000000000000000000000000000000000000000000000000080
  • 1: 0x0000000000000000000000000000000000000000000000000000000000000020
  • 2: 0xfcf771399d75a67a6d0e730ae98d34c40b6bfe6ebf8053b98ddf4da8c2706250

There are in total five LOGn opcodes, LOG0 to LOG4, where n indicates the number of topics in the log. Topic0 is always the identifier of the event type, defined by the hash of its signature, but it can be skipped by using LOG0, which specifies an anonymous event. Each additional topic requires another slot in the stack, pushing that many more arguments out of the reachable list.

This analysis shows that an event with one argument prevents one variable of being used, because topic0 is placed in the stack before the event data. This raises a couple of questions:

  • What if we have more topics? Are they placed in the stack before the data as well?
  • And what is the impact of more event arguments, are they PUSHed after or before the topic?

To test that, I’ll change the contract again. Notice that events can have any number of arguments, and up to 3 of them can be indexed. Indexed arguments become topics, while the others are lumped in the data section. My hypothesis, at this stage, is that each topic (indexed argument) will be placed in the stack before the data, and so will prevent the access to more of the early variables.

In my tests, I covered several scenarios, but they all lead to the same conclusion so I will save you the minute details. I will just illustrate with another interesting and counter-intuitive case, and then draw final conclusions.

First, let’s try this version of the contract, where the event has one indexed value and two non-indexed ones.

pragma solidity ^0.4.24;
contract TestStackError {
event LogValue(uint indexed a1, uint a2, uint a3);
function logArg(uint a1, uint a2, uint a3, uint a4,
uint a5, uint a6, uint a7, uint a8,
uint a9, uint a10, uint a11, uint a12,
uint a13, uint a14, uint a15, uint a16
) public {
emit LogValue(a2, a3, a4);
}
}

The bytecode for this function (after the function dispatch) until the event is emitted is this:

265 JUMPDEST
266 DUP15
267 PUSH32 a5397a5faa0ec7cfb89428503b91a13bbd737592f7561e6773fa3e1458c8735c
300 DUP16
301 DUP16
302 PUSH1 40
304 MLOAD
305 DUP1
306 DUP4
307 DUP2
308 MSTORE
309 PUSH1 20
311 ADD
312 DUP3
313 DUP2
314 MSTORE
315 PUSH1 20
317 ADD
318 SWAP3
319 POP
320 POP
321 POP
322 PUSH1 40
324 MLOAD
325 DUP1
326 SWAP2
327 SUB
328 SWAP1
329 LOG2

The opcode that emits the event is LOG2. This means we have two topics, one the default topic0 (ie the event signature) and the other the only indexed argument in the event signature. The remaining two values are grouped in memory.
If we check Ethervm for this opcode, we see that the last value read from stack, and the first to be pushed onto it, is topic1, that is, the indexed argument — a2. Initially, this is placed at position 15 of the stack. The opcode DUP15 places a copy of the value at the top of the stack, and consequently pushes all the other arguments down. From now on, for example, a2 is in position 16, and a1 is in position 17.

The next instruction pushes a 32-bit value to the stack, that simply corresponds to topic 0. This value is hardcoded. This also has the effect of pushing again the arguments down. Now, a2 is in position 17.

The following instructions are two DUP16 opcodes. The first one copies the value at position 16, which is currently the third argument, a3. But since this pushes a new element onto the stack, when the next opcode is called DUP16 will copy the fourth argument to the function, a4. At this stage, at the top of the stack we have the data for the event (two words), the indexed argument and the event unique identifier.

The following lines copy the first two values to memory:

  • (302–305): places the contents of memory 0x40 at the top of the stack, twice. This is the position in memory where the event data will be located (and is 0x80 in my execution).
  • (306–308): places the first data word at the first free position in memory (ie places a3 in position 0x80
    )
  • (309–311):places the next free position in memory at the top of the stack
  • (312–314): places the second data word at the next free position in memory (ie places a4in position 0xa0
    )
  • (315–321): calculates the next free position in memory and leaves it at the top of the stack, after eliminating values that are no longer needed.
  • (322–327): finds the length of the data submitted to the event, by subtracting the initial address of next free position in memory from the current value of that position (held at the top of the stack).
  • (328): reorders the first two elements of the stack, making the first element the beginning of the event data, and the second address the length of this data.
  • (329): finally calls the logging opcode.

I gave this detailed explanation so that you can understand how this process works, if you wish. In that case, perhaps you can now explain the next apparent oddity. Change only the signature of the event to:

event LogValue(uint a1, uint indexed a2, uint a3);

Yep, another Stack Too Deep error. Can you see what is causing it?

……

………

The bytecode does not change much. We still have the same number of topics, so the opcode at the end will still be LOG2. And it still expects to receive its arguments in the same order, that is, the topics first, then the data.

Now, the second topic must be loaded first, so a3 would be the first value to be pushed to the stack with a DUP14. Then topic0 would be pushed. Now, the EVM would place at the top of the stack the two arguments it needs to store in memory, a2 and a4. These were originally at positions 15 and 13. However, the EVM has made two pushes already, which makes these positions 17 and 15. It is impossible to place the first value in the stack (DUP17 does not exist) and so the compilation errors.

So now that we understand this, I try changing just one more thing, the log function to:

emit LogValue(a3, a2, a4);

This code works, since it corresponds very closely to the last block before I changed the order of the indexed arguments. In that code, the indexed value of the event was called with a2. In this version, it is still a2 that is passed to that position, and the others remain the same. The bytecode explanation is virtually the same.

Conclusion

This has been a long post. If you have arrived this far, it is worth leaving you with an organised view of what is happening, so that you can go back to your programs and think if your “Stack Too Deep” errors could have been caused by a similar behaviour. Although this post covers only the case of emitting events, other functions will use other opcodes, but will still have the same logic, in copying the function arguments (or intermediate values) to the stack when some computation is needed.

So here are some streamlined notes to keep in mind:

  • When a function is called, a stack frame is created. This includes, from bottom to top:
  • the function selector
  • the return address
  • the leftmost value-type argument of the function
  • the rightmost value-type argument of the function
  • “Stack Too Deep” errors depend on the central opcode of an action (eg arithmetic, hashing, calling another function, emitting events, etc.)
  • If these central operations are performed on pure function arguments, the order in which they are passed to the function may decide the occurrence of a “Stack Too Deep” error. (Stack slots can also be used for intermediate calculations and local variables, but I intend to study those in a later post.)
  • It is crucial to know the number and order of the arguments for the opcode. These arguments are typically read from the stack (the only exception is the PUSH opcode).
  • Opcode arguments have to be pushed to the stack before executing the opcode. Each PUSH moves the function arguments down at least one slot. The function arguments deeper in the stack are the ones that were processed first, that is, the leftmost ones in the function signature.
  • If some of the function arguments are not used in that opcode operation, then they should come first in the function signature, to reduce the chances that opcode arguments will be off-reach when they need to be stacked.
  • Opcodes use arguments at different levels in the stack. Deeper levels are pushed first. If an argument is pushed after another, it should appear in the function signature after the former as well, otherwise it would push the other one down the stack before it could be used. Example:
  1. Consider an event with two indexed arguments t1 and t2 in this order, that is called inside a function with several arguments, among which a1 coming before a2
  2. If the event is emitted with t1 = a1 and t2 = a2, the opcode LOG3 will be called.
  3. Before calling this opcode, t2 = a2 will be pushed first into the stack.
  4. This will push a1 down and put it at risk of being unreachable when the time comes to push the value of t1 = a1.
  5. This would be avoided if a1 came after a2 in the function signature, since it would be higher in the stack than a2. Assuming a2 was reachable when it was pushed, so would be a1 afterwards.
  • The above post concentrated on LOGn opcodes only, in particular on versions requiring 3 or 4 arguments in the stack. A more difficult case will be calling functions in other contracts or libraries, since the opcodes CALL and DELEGATECALL take 7 or 6 input arguments each, with a lot more possibilities of interaction between the opcode and function arguments.

I hope this gives you some clues on how to debug and handle “Stack Too Deep” errors. There is a lot more to say, but that will have to wait for other opportunities.

Until next time.


Alexandre Pinto — Blockchain developer at Artos (Aventus Ecosystem Party)

Alex is a software engineer at Artos, our ecosystem partner, working on the blockchain engineering team. He has 20 years of experience working in technology, completing a PhD in Computer Science as well as a post-doctorate in Cryptography. As part of his research, Alex has published papers on Kolmogorov Complexity, Cryptography, Database Anonymization and Code Obfuscation.

Pinto also spent seven years lecturing at the University Institute of Maia, including directing the degree programmes for BSc Computer Science and Information Systems and Software.

This article was originally posted on his blog.

Since you are here, we would love if you connected with us on Telegram,Reddit, Twitter, Facebook, Youtube, Instagram and LinkedIn.

Also, we have started a LinkedIn Group for ticketing developers/other developers to engage, join us and start a conversation.