 Hey, guys. My name is Alex. I am a lead developer at Tally Ho. And I'm here to talk to you about EVM assembly. So the two things we're going to cover in this talk is, first, what is EVM assembly? And second, how to read the op codes, how to read op codes and trace an EVM transaction. But I think before we jump into that, it's pretty important to tackle a question of, why do we care? Like, why would we care about EVM assembly? I think that there's a few reasons. So any of the code that we write as developers is pretty far abstracted from the code that a machine actually interprets and executes. And the closer we get to this real code, and the further we get from our abstractions, the more we can reinforce our mental model of what the machine is actually doing. And I think, in general, learning from first principles is a fantastic way to progress as a programmer and to just build solid and bulletproof mental models of what your code is doing. So what is EVM assembly? Well, let's start with what is the EVM? So you've probably heard of EVM, ZK EVM, EVM OS. There's a lot of terminology out there right now. But at its core, the EVM is just a stack machine. It's something that takes in instructions, builds up a stack, and then operates on that stack. Most of the operations consume values from the stack. So add takes two values from the stack, adds them, you're left with one value. Multiply takes two values from the stack, adds them, you're left with one value, et cetera. But there are exceptions to this. I think the most notable one is push. So we have push1 through push32, which pushes between 1 and 32 bytes onto the stack, respectively. So let's go a little bit more in depth into the EVM stack machine. So the stack machine has a depth of 1,024 items. Each item is a 256-bit word. If you're not sure what a word is, that's OK and it's not really relevant for this talk. It is a slot on the stack machine. During execution, the EVM has memory that does not persist between transactions. It also has storage that does persist between transactions. When we talk about writing to the blockchain, that's what we mean. We are storing things in the EVM. And also Compile Smart Contract By Code executes a number of EVM op codes. So we have some that you might be familiar with. If you've looked at x86 assembly, like xor, and add, and sub, and we also have a number of blockchain-specific op codes in the EVM. And we'll dive into more of these a little bit later. And then I think the last important thing about the stack machine is that each operation costs a certain number of gas. So when we pay gas for our transactions, we're essentially paying a little bit or a lot of gas for every single operation that the stack machine executes. All right, so now that we have some understanding of what an EVM is, what is assembly? So assembly is something that lives in between the code that we as developers write and between the code that a machine interprets. So on one hand, we have Solidity, which is nice. Some people think easy-ish to read, easy-ish to reason about, and then on the other end of the spectrum, we have bytecode, which is pretty much impossible for humans to read, but very easy and very efficient for machines to read. And then in between Solidity and bytecode, we have assembly. So this is kind of what it looks like, and it is an intermediate language or an intermediate representation. And what that means is it is something that lives in between the code that we write and bytecode. And the opcodes that you see there are what are actually running in the EVM. So the Solidity or the Viper that we're writing is compiled down to opcodes and then running in the EVM. And that pattern is what lets us have multiple smart contract languages that compile down to EVM and then are able to be executed. Or sorry, that compile down to assembly and then are able to be executed. All right, so let's trace a transaction. I have a website up here, www.evm.codes. You might find it useful. You don't need it to follow along, but it's a fantastic reference that has every single opcode in the EVM, how much gas it costs, and what it does. All right, so here I've written this incredibly useful smart contract. We have one storage variable, total supply, and we have a public function that anybody can call that sets a total supply to eight. So very simple smart contract, not much going on here, not even sure why anybody would use it. So the opcodes representing this contract should be pretty simple. Well, we can take a look. So SolC provides us with a handy little command where we can pass a contract to SolC with the opcodes flag, and it will output a opcode representation of that contract. So our simple contract becomes that, which is kind of unreadable. But by the end of this presentation or this talk, you guys will be able to read some parts of it, some that are common to all transactions that we see in the EVM, and some that are specific to the contract that we wrote. So let's go ahead and trace a specific transaction. Imagine that we've deployed our contract at some address, and then we are using ethers to call ultrasoundmoney.set total supply, the transaction that we defined in the beginning. Great. We submit to the transaction. It's been mined. It's part of the blockchain. Let's take a look at it. So Gath has this very handy command called debug trace transaction. If you're here for the previous call, you're probably more familiar with it than most. And what we can do is we can pass in the transaction hash where we invoke that sendTotalSupply function and get an opcode trace, as well as a historic representation of the stack of that transaction. So basically, find out everything that happened in it. So let's trace the opcodes of that transaction. You'll see here that there are some lines between these opcodes, and I've divided them into four sections. So that is more so just for clarity, because I want to focus on three parts that are common to every transaction and one part that is common to ours. And I also want to note that what I'm going to show you is not necessarily a one-to-one representation of what you'll see when you send debug trace transaction. For example, we're going to be converting hexadecimal numbers to decimals, because hexadecimals are hard for humans to read. We're going to be showing Booleans as true or false, but that's just so that what's happening here is easier to follow along with what's happening here. So let's start with the top, this little section. So we have push10x80. What we're doing here is we're pushing 128 onto the stack. 128, sorry, 0x80 is the hexadecimal representation of 128. Then we're going to push 64 on the stack, and then we're going to call a function called mStore, which is going to store the value 128 at the offset 64 in memory. So what's going on here? We don't have 128. We don't have 64 on the stack, or we don't have 128 or 64 in our contract. So why is this happening? Well, what's happening is Solidity uses the memory area between address 0 and address 0x7f, or 127, for internal purposes, and stores data starting at address 0x80 or 128. So this is Solidity doing some boilerplate internal memory management for us. Fantastic. We don't have to worry about this as Solidity developers. This is just a straight win. Wonderful. All right, so now we're going to jump to line 25 in our list of opcodes. Between line 3 and 25, there's some more boilerplate stuff. There's a validation of message.value and making sure you can't send ether to a non-payable function. But that is not really relevant to us because our function isn't payable, so we're going to go ahead and skip that. Great. So on 25, we're going to push 4 onto the stack. Once again, where is this 4 coming from? And then 26, we're going to encounter our first blockchain specific opcode. And what we're doing here is we're pushing the size of the input data onto the stack. And as you can see, we pushed 4 onto the stack. Now we have 4 on the stack again, so that means our input data size is 4 bytes. But where did this come from? We didn't send any ether, and we didn't send any arguments along with our function call because there were no parameters. It was just the public function that we invoke. So why is our call data size 4? Well, the answer to this is that when we call getTotalSupply in ethers, under the hood, ethers is going to hash that getTotalSupply function into its function signature and send that along with the input data when we send that transaction. All right, so we know that we have 4, and we know that our call data size is 4. What's going on next? Well, LT, as you may have guessed, checks if the input data is less than 4. And what's actually happening is it's looking at the top value of the stack, looking at the second from top value of the stack, and seeing if the second is less than the top value. In our case, it's not because 4 is not less than 4. So we push false onto the stack. Remember, this would be represented as a 0 or a 1. And then we push 0x280 or 38 onto the stack. We'll come back to that in just a second. The next instruction that we see is jumpI. And I think of that as jumpIF. So what that instruction is telling us is if the second from the top value is true, jump to the program counter represented by the top value on the stack. A simple way to think about that is if the second from the top value is true, jump to line 38. Since the second from the top value is not true, we don't jump anywhere, and this check executes. And essentially what's happening here is that since function signatures are 4 bytes in length, if the call data size is less than 4 bytes, we know that we can't possibly be calling a valid function. And then the function gets reverted if the call data size is less than 4. So with that idea of function signatures in mind, let's take a look at the next section. So call data load is another blockchain-specific opcode similar to call data size. Except instead of pushing the size of the call data onto the stack, it pushes the actual call data onto the stack. Remember that the size of our call data was 4 bytes. So here we have 4 byte hexadecimal value on our stack that seems relatively arbitrary. But some of you are probably correctly guessing that we are actually pushing our function signature onto the stack. On the next line, we have push 4, and we push that same exact function signature. What's happening here is the contract is checking, OK, I am being sent this function signature. Does it match this function signature? And if the answer is yes, represented by the opcode equal, so equal takes two values from the stack and returns, sorry, the top two values from the stack and returns true or false if they're equal, we get a 1 or a 0. And in our case, it's true. So once again, we're in that jump I situation. So we have true on the stack. We push 45 onto the stack. And now on line 36, we are, excuse me, on line 36, we're going to jump to line 45 because line 32 was equal to line 33. And hey, we know that this is the function that we wrote because it's the function that we tried to invoke. Fantastic. And this is how the EVM determines which functions to call. If you have many functions in your smart contract, you are going to have many lines of call data load. Does it equal this function signature? Does it equal this function signature? Does it equal this one? And then if it matches one, it'll jump to that line. And if it doesn't, it'll revert because you cannot call a function that doesn't exist. Awesome. OK. So now we're finally in that very simple function that we wrote. If you recall, it was total supply equals 8. What we see on line 45 is a common command called jumpDest. And all that does is it marks that line 45 is a valid destination to jump to. You cannot jump to any arbitrary destination. You need to jump to a valid one or you'll revert. So this just lets us know that, hey, we can jump here. What's the next thing we see? Push 8 onto the stack. All right, awesome. This is definitely code that we wrote. Total supply equals 8. I remember 8. This is us. Cool. But after that, we pushed 0 onto the stack. So where does the 0 come from? What are we talking about? Well, if you recall, in our very simple contract, we only have one storage variable. And that storage variable, because it's the only one defined, is in the first storage slot. So the first storage slot or the 0 storage slot would be an array indexing. So this is awesome. We have everything that we need. We have 8. We have 0. All we have to do now is save this and we're done. But wait a second. We see that in our transaction. We duplicated. We ran dupe 2. So we duplicated the second from the top word of the stack. Then we swapped the first and second words on the stack. Interesting. And then, all right, great. So now we ran sStore. So sStore, store and storage, we're saving 8 to the 0 with storage slot. That's what we initially wanted to do. But then we also have a pop after that, because we have this extra 8 on here. And what is this 8 doing? Why is it here? This doesn't make a lot of sense, right? Why are we duplicating and swapping here? Why are we popping at the end? Solidity is doing extra memory management that we don't want, right? Because we want to be as gas efficient as possible and be as gas efficient as possible. So we should jump down into UL and try to optimize this code, right? Well, let's talk about gas optimization using UL. If you're not familiar, UL is simply a language that we use to write assembly code in Solidity. It's not exactly like writing opcodes. It's not exactly like writing EVM assembly. It has four loops, if, and switch statements. And it also disallows some commands, like jump statements, because they quickly become very, very difficult to reason about. And yeah, all right. So we added UL. We have this optimized that total supply function. It's awesome. We have an S store with a 0 and an 8. So we're saving 8 to the 0 with slot. This is great. This is going to be way better, right? Go ahead and let's go ahead and measure our contract. So once again, let's go ahead and look at the opcode representation of our contract first. So once again, we take a look at the opcodes. And we see that, in fact, optimized set total supply does have less opcodes than set total supply. This is fantastic. We know that opcodes cost gas. We see that set total supply and optimized set total supply have the same opcodes, except set total supply has more. We just saved a lot of time and money, right? But let's measure it to be extra, extra sure. So here, this is, I think, hard hat gas reporter. And we've ran the set total supply function 100 times. And we see that the average gas cost is $23,599. Cool. And then, hey, now we ran optimized set total supply. And we see that the gas cost is $23,591. Awesome. 8 gas. We have just saved our users like an enormous amount of money, right? Well, not really. I kind of rugged you guys here. So if you notice, optimizer enabled is equal to false. If you're not familiar with optimizer, it's something that you can use in hard hat and other tools when you're deploying smart contracts. So let's go ahead and enable our optimizer in hard hat. Rule set runs 200, enable it, and re-measure. And in fact, when we have the optimizer enabled, it looks like the two functions cost approximately the same amount of gas, right? So all our time studying UL and learning it and learning about S-Tor was wasted, right? Well, I wouldn't argue that exactly. But I will say that optimizing smart contracts is hard. And chances are that you're not going to do a better job than the compiler unless you really know what you're doing. Contracts containing assembly are generally harder to reason about and harder to audit than contracts written in Solidity and Viper. So what you might gain in gas optimization, you will probably be making a trade-off in a contract or user security. And the other thing that's important is if you're writing your own assembly code, always measure and make sure that your implementation is better than the compilers. Because chances are, there are some very, very, very smart people working on the compilers and optimizers that know something that you don't when it comes to memory management or safety. And I guess that's essentially my last point. Remember, a lot of the memory management stuff Solidity does under the hood is there for safety reasons. And just because an opcode looks like it's unnecessary doesn't mean that it actually is. That being said, optimizing in UL and in an assembly is definitely something that is needed and useful, especially in DeFi and on Mainnet, where our gas costs are lower now but quite higher than a lot of the other chains. But yeah, I guess do so at your own risk. So thank you, guys. Once again, my name's Alex. I'm a lead developer at Tally Ho. And we are hiring Solidity and TypeScript developers. So if this kind of stuff is interesting to you and you're looking for a change, please reach out. And then finally, I want to give a big thanks to Gilbert Garza. He had a lecture at Xerox Macra that inspired this talk. And there are a few resources on here if you would like to dig deeper on any of the topics covered today. Thanks so much. Thank you. We would have some time for questions if you'd like. Why the optimized function spend the same amount of gas if it has more, sorry, less op-codes? So the op-codes that we were looking at were for the unoptimized functions. And we were looking at them because it is actually, it's easier and possible to dissect them in a talk. So when we saw those two op-code, I guess, of optimized, what was it, setTotalSupply and setTotalSupply, those are the unoptimized op-codes. And then after the optimizer runs, the op-code representation of those two functions actually becomes the same. What were those two op-codes, two extra op-codes, doing the swaps we're doing on the unoptimized compiled version? The optimized compile version was effectively the same as our op-codes. Here, I can show you. So here we have our unoptimized setTotalSupply and our optimized setTotalSupply in our code where the optimizer has not run before. After the optimizer runs, both of these functions have the same exact op-code. And in fact, even though we have two functions in our contract, the optimizer is smart enough to know that they're doing basically the same thing. When we were talking earlier about these selectors over here, so this selector jumps to a destination, and then there would be a different function selector for another function. For the other function, they would actually both jump to the same destination because effectively, what they are doing is the same. What would be some good use cases for managing the assembly code within contracts? And do you recommend any good resources for learning UL? Yeah, so I think that the best use case that I have seen is a fairly common one, where if you're adding two numbers that you are 100% sure will not overflow the integer limit, then in assembly, you can add them with a there's an unchecked flag, which basically tells the compiler to not check for integer overflow. I believe that in Solidity 9.0 something, in some fairly recent version of Solidity, safe math became, I guess like, there are a lot of checks that run under the hood. You can turn off those checks if you're sure that you don't need them, and that'll save gas. But once again, that can be very dangerous. And as far as resources for learning UL, I think just like reading smart contracts and trying to figure out what's going on there is the best way to do it. There are unfortunately no good resources to learn UL, or really EVM assembly, which is what I wanted to just talk, just like, yeah. Just to follow up on that, have you heard of Trim? Yeah. OK, so my question is this, right? Like say for instance, if I'm writing Trim and I'm basically transpiling my smart contract into Trim, like what are the pros and cons to that, as opposed to doing it UL style, like within Solidity and then using UL? Unfortunately, I can't answer that question because I'm not familiar enough with Trim. I do believe that Gilbert, the person that I mentioned in my talk, was the person that developed Trim, or one of the developers, and I would encourage you to reach out to him on Twitter, but I'm not familiar enough with Trim to answer that. Hey, thanks for the talk. I saw somebody asked about UL. Today there is an awesome workshop about that. It's like two hours long. You should watch that. And then I had a question for Optimizer Runes. What number do you recommend? What number? I just choose 200 because it's standard. I know that there is a, or it's not standard. It's just like standard in what I've written. I know that there is eventually a trade-off size between contract size and gas efficiency that you get if you set your Optimizer to, I don't know, 500,000, a million, or maybe less, but I generally go with 200. I don't work in the DeFi space, so I'm not too concerned with ultra-ultra-ultra optimization, but 200 seems to be a good number for what I'm seeing. If that was it, thank you so much. Thank you, guys.