Abdulla's Blog

Aug 22, 2022

0xMonaco

Introduction

Last weekend saw the release of the highly anticipated Paradigm CTF. Amongst the many challenges of varying difficulty, there was a PvP game which involves racing cars against each other. The only catch, however, is that the cars would be controlled by smart contracts! Over the course of 24 hours, developers would deploy new contracts to try and beat each other and to end the competition with the highest ELO.

Within the last minutes, it was neck-and-neck between three teams (OpenSea, JustLurking, and myself) with OpenSea just snatching the win in the very last races. Although I'm gutted I didn't win, I'll take the 2nd place finish. I will include the source code for my car at the bottom of the post, but I'm not sure how much it makes sense without understanding the thought process that went into writing it! Feel free to scroll down to the bottom to have a look.

Alt Text

The Beginnings

When the competition started on Saturday night, I was very excited and deployed my first car. My first car (MaxBidCar) was very simple. Just buy 11 acceleration, every round. I didn't win many races, but I certainly managed to get off the start line quickly. I then started to realise that the strategy you should take depends completely on the strategy taken by other players. In this example, I realised that if I had started quickly (because I bought up all the acceleration) there was a chance that the guy behind me hadn't coded any shell mechanism. Indeed, the only races I managed to win are the ones where I didn't get shelled.

I realised I needed to be more clever than this, so I decided to change the amount of acceleration being bought. There was a trade-off between buying as much acceleration as possible vs being economic. I started to think that the optimal strategy would be an economic strategy. After re-reading the rules, I tried to understand the exponential pricing algorithm and come up with a solution. I started with the following:

// This is cheap speed
while (monaco.getAccelerateCost(1) < 20) {
    ourCar.balance -= uint24(monaco.buyAcceleration(1));
}

// shell someone if shells are cheap, and we are not in lead
if (ourCarIndex != 0 && monaco.getShellCost(1) < 200) {
    ourCar.balance -= uint24(monaco.buyShell(1));
}

The documentation had suggested that the "default" price for acceleration was 20, and for shells was 200. Therefore we buy these whenever items whenever we can get them at a bargain - regardless of what our current speed is! I noticed that this strategy was very effective in certain scenarios. When the other two racers were very aggressive, they would spend all their coins early on in the game. Eventually they will both be priced out of taking additional turns. At this point, I can use all the cheap moves to slowly sail across the finish line.

Economics and Meta-Game Theory

Any competitive gamer will understand the concept of the 'meta' or 'metagame'. Over time, it became apparent that multiple different strategies were evolving - some would frontload the start of their race, others would save their economy for a push at the end. Some people had different thresholds for which they would prioritise their own speed vs shelling others to reduce their speed. This metagame became apparent to me after watching some of my own races. It almost appeared like the game evolved into rock paper scissors, where the three different behaviours were "acceleratooor", "shellooor", "economicooor".

As my previous strategies had been quite poor, my elo dropped down to the 900s. This became quite frustrating as it became clear that the metagame at elo ~900 was different to ~1100 and ~1300 and ~1500 were all different (Although I didn't have that much time to learn the top meta!). A user that wastes all his coins buying acceleration should be punished by a swift shell from the player behind them, however in some elos there were many cars which never bought any shells! So a "Greedy" algorithm which just max bids acceleration, and hopes that the player behind them doesn't shell them becomes very effective.

Going back to my algorithm, I wanted to expand on the code I highlighted above. I liked this algorithm because it would be cautious when the other two racers were aggressive, and was aggressive when the other players were passive. I decided to converge around the current price of acceleration/shells as an indicator for whether these actions were overbought or not. If acceleration was expensive, I would just sit and wait for it to slow down whilst also shelling everyone in front of me. Unfortunately, I found that most of the time, my car was being too stingy and avoided paying for most actions and so just sat at the start line for most of the race.

After finding out that my car was ending the races with most of its coins, I realised I could be more spend more buying actions than the default cost. Deciding to buy acceleration whenever its price was less than 20 meant that I didn't buy acceleration a lot of the time, especially at the end of the races when you need acceleration the most! I then created a function getMultipliedInput() which I would use to scale this value. At first I tried 150% scaling (so I would buy acceleration at 30 instead of 20), and then 200%. I then experimented with using other parameters as part of this scaling. I tried looking at the difference between my balance and the average of the others players balance - this wasn't an effective strategy!. I also looked at the number of turns, and distance travelled as methods of estimating "how close are we to the end of the game".

In the end, I chose to use an algorithm that was very similar to the compound interest rate formula utilising a kink. I looked at distance travelled, and number of turns taken in order to calculate how much my car was willing to bid on acceleration/shells. I was able to use the test file supplied in the project folder to test different cars by running forge test -vv to test my car against other example cars. I was able to use this to set some initial variables for the kink distance and sprint factor. This was my first time using foundry, and I was impressed by the speed I was able to iterate over making changes to these variables.

Summary

You will have noticed throughout the article I have made many assessments to the functioning of my car. Indeed, it is pointless to make changes to your cars code without having a hypothesis to validate/invalidate these changes. There are many different frameworks for continuous improvement, but broadly they involve 1. coming up with a theory 2. making a change 3. assess response of change 4. repeat. I don't think it's any coincidence that the three teams that came in the top three, all had approximately submissions. I feel like any less than this implied that users were less willing to experiment/make changes with their code. People with more submissions would probably not have had sufficient time to assess the impact of their changes!

Naturally, I would also assume that other people may have spent their time working on the rest of the CTF challenges. So, thanks again to Paradigm for creating an entertaining set of challenges. This was definitely the most amount of fun I've had on-chain, and would love to see some on-going development for 0xMonaco. Now, here's the code you all wanted to see:

The Code

// SPDX-License-Identifier: MIT
pragma solidity 0.8.13;

import "./Car.sol";
import "forge-std/console.sol";

// elo: 965.90 when we start

contract ZoomCar is Car {
    constructor(Monaco _monaco) Car(_monaco) {}

    uint256 internal constant KINK_DISTANCE = 700;
    uint256 internal constant KINK_FACTOR = 3_000;
    uint256 internal constant SPRINT_FACTOR = 11_500;
    uint256 internal constant FINISH_DISTANCE = 1000;

    function getMultipliedInput(
        uint256 input,
        uint32 distance,
        uint16 turn
    ) internal view returns (uint256 output) {
        if (distance < KINK_DISTANCE) {
            output =
                input +
                ((turn * 3) / 4) +
                ((input * ((distance * KINK_FACTOR) / KINK_DISTANCE)) / 1000);
        } else {
            output =
                input +
                ((turn * 3) / 4) +
                ((input *
                    (KINK_FACTOR +
                        (((distance - KINK_DISTANCE) * SPRINT_FACTOR) /
                            (FINISH_DISTANCE - KINK_DISTANCE)))) / 1000);
        }
        console.log(output);
    }

    function takeYourTurn(
        Monaco.CarData[] calldata allCars,
        uint256 ourCarIndex
    ) external override {
        Monaco.CarData memory ourCar = allCars[ourCarIndex];

        uint16 turn = monaco.turns();

        // In the final sprint, if we are 2nd:
        // shell the guy ahead if he finishes before us
        if (ourCarIndex == 1 && allCars[ourCarIndex].y >= KINK_DISTANCE) {
            uint256 our_ttg = 1 +
                (FINISH_DISTANCE - allCars[ourCarIndex].y) /
                allCars[ourCarIndex].speed;
            uint256 their_ttg = 1 +
                (FINISH_DISTANCE - allCars[ourCarIndex - 1].y) /
                allCars[ourCarIndex - 1].speed;
            if (
                their_ttg <= our_ttg && ourCar.balance > monaco.getShellCost(1)
            ) {
                ourCar.balance -= uint24(monaco.buyShell(1));
            }
        }

        // This is cheap speed
        for (uint256 i; i < 10; i++) {
            if (
                monaco.getAccelerateCost(1) <=
                getMultipliedInput(20, allCars[ourCarIndex].y, turn)
            ) {
                ourCar.balance -= uint24(monaco.buyAcceleration(1));
            }
        }

        // if we are in lead, price gouge shells
        if (
            ourCarIndex == 0 &&
            monaco.getShellCost(1) <=
            getMultipliedInput(200, allCars[ourCarIndex].y, turn)
        ) {
            ourCar.balance -= uint24(monaco.buyShell(1));
        }

        // If we aren't leading:
        if (ourCarIndex > 0) {
            uint32 ahead_speed = allCars[ourCarIndex - 1].speed;
            uint256 shell_cost = monaco.getShellCost(1);

            // If we're in last, and the guy in first is faster than second
            // We shouldn't shell, because we want the second guy to shell the first
            bool shouldShell = true;
            if (ourCarIndex == 2) {
                if (ahead_speed < allCars[ourCarIndex - 2].speed) {
                    shouldShell = false;
                }
            }

            // shell person ahead if their speed is greater than cost of shell
            if (
                shouldShell &&
                (shell_cost <
                    getMultipliedInput(
                        ahead_speed * 16,
                        allCars[ourCarIndex].y,
                        turn
                    ) &&
                    ahead_speed > 1)
            ) {
                ourCar.balance -= uint24(monaco.buyShell(1));
            }
        }
    }
}

Jun 27, 2022

Solidity vs Vyper (Part 1 - Arithmetic overflow)

On ethereum, there are two main smart contract languages which are currently being used to deploy smart contracts: Solidity and Vyper. In this series of posts, I will discuss some of the peculiarities of writing smart contracts and use examples from both languages to explain them.

The first contract that we will look at is the ERC20 token, of which there are many examples such as Dai, WETH and UNI. The smart contracts for these tokens act as a distributed ledger, which keeps track of the balances of all users. When a user transfers their tokens to another user, the smart contract must first check whether the user has sufficient balance, and then update the balances of both the sender and receiver. To highlight some of the differences between Solidity and Vyper, I will display just the code required to transfer tokens from the sender to another user. Full implementations can be found for Solidity and Vyper.

Solidity

pragma solidity ^0.8.0;

contract ERC20 {
    mapping(address => uint256) public balanceOf;

    function transfer(address to, uint256 amount) public returns (bool) {
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        return true;
    }
}

Vyper

# @version ^0.3.0
balanceOf: public(HashMap[address, uint256])

@external
def transfer(to: address, amount: uint256) -> bool:
    self.balanceOf[msg.sender] -= amount
    self.balanceOf[to] += amount
    return True

It should be apparent that both smart contract languages are very similar in their implementation of an ERC20 token. Both contracts utilise a mapping from an address to a uint256 in order to signify a users balance.

It is important to highlight what would happen with arithmetic over/underflow. If we allow a user to send tokens when they have an insufficient balance, this would result in that user ending with a negative amount of tokens. As the balance is stored as a uint256, this would underflow - causing the user to have a balance in the region of 2 ** 256 which would obviously be a very serious bug! All versions of Vyper natively check for these arithmetic errors, and cause the transaction to be reverted. This feature was also added to Solidity in version 0.8.0. Anybody choosing to write a contract in an older version of Solidity will need to manually check for over/underflow or utilise a library to do this.

To give an illustration of what would happen if we use an older version of Solidity, I have used the exact same code as presented above but changed the compiler version to 0.7.0. If we run the code, we get the following:

>>> sender = accounts[0]
>>> receiver = accounts[1]
>>> overflow_erc20 = sender.deploy(ERC20)
Transaction sent: 0xd7f0c229b122fb0795d03693222b527c5a7f0077651f36c93ec9e58e01125d8a
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 0
  ERC20.constructor confirmed   Block: 1   Gas used: 116515 (0.97%)
  ERC20 deployed at: 0x299784aE2AF66133155848a9D2bcAde3B9f5b32c

>>> overflow_erc20.transfer(receiver, 1, {"from": sender})
Transaction sent: 0x4383921e24d79d235318c2fbad56a191586ab113f1c515ad94bd500f42c156ad
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 1
  ERC20.transfer confirmed   Block: 2   Gas used: 63634 (0.53%)

<Transaction '0x4383921e24d79d235318c2fbad56a191586ab113f1c515ad94bd500f42c156ad'>
>>> overflow_erc20.balanceOf(receiver)
1
>>> overflow_erc20.balanceOf(sender)
115792089237316195423570985008687907853269984665640564039457584007913129639935

This overflow error has resulted in the user now having more tokens than there are atoms in the universe!

Developers must be very careful to ensure that these errors do not occur. In particular, it is important to be aware of the different behaviour provided by different compiler versions. When deploying any smart contract, it is important to have the code reviewed (either by external reviewers, or full audits) to reduce the risk of accidentally introducing any error that could result in significant loss for its users.

Feb 03, 2022

On the topic of gas optimisation

Introduction

A few topics recently crossed my attention. A user on reddit lost 500K by sending WETH to the WETH contract address

Reddit: Did I just lose half a million dollars by sending WETH to WETH's contract address?

There has been some discussion recently on gas optimisations in smart contracts, which has been particularly relevant due to increasing gas prices.

Should a smart contract prevent users from performing actions that would result in the loss of user funds?

Adding extra checks (such as ensuring that the sender and receiver are not the same as the contract address) adds an additional gas stipend onto each transaction.
An argument could be made that it should be the responsibility of wallets and front-ends to prevent these transactions from ever reaching the blockchain. However, given that it is fairly common for tokens to be sent to the contract address, we know that this is not the case.

I thought it would be a good idea to look into this further to decide for myself whether it made sense to add these checks from an economic perspective.

Idea

I would write two smart contracts representing ERC20 tokens: an 'unsafe' ERC20 which allows any transfer (ofcourse, the standard rules of balances and approvals will remain) and a 'safe' ERC20 which checks that the receiver and sender are valid addresses (by ensuring that they are not the zero address, or the contract address)

From these contracts, I will get an estimate of the average gas cost for each function.

I will then try and use these figures to get an idea of the gas saving that would have occurred during the lifespan of the WETH contract. I will also get an idea of how much WETH has been 'lost' after being sent to the WETH contract and compare these figures

Data

All contracts/scripts are available on Github.

The gas costs for each of the functions is displayed below, I will be using the average for my calculations. The average has been calculated from 1000 executions with random variables

safeERC20 <Contract>
   ├─ approve      -  avg:  42565  avg (confirmed):  42565  low:  24521  high:  44117
   ├─ transfer     -  avg:  31945  avg (confirmed):  31945  low:  27012  high:  35808
   └─ transferFrom -  avg:  20442  avg (confirmed):  20442  low:  18819  high:  29250
unsafeERC20 <Contract>
   ├─ approve      -  avg:  42519  avg (confirmed):  42519  low:  24475  high:  44071
   ├─ transfer     -  avg:  31875  avg (confirmed):  31875  low:  26942  high:  35738
   └─ transferFrom -  avg:  20400  avg (confirmed):  20400  low:  18782  high:  29176

In order to get a list of transactions on the WETH contract, I initially performed the following query:

SELECT
    input, gas_price, gas
FROM `bigquery-public-data.crypto_ethereum.transactions` as tx
WHERE
  tx.to_address = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'

I then realised that this misses out on the majority of transactions on the WETH contract given that many contracts will sub-call the WETH contract, therefore the to address on the transaction will not be the WETH address.

My new updated query looks like:

WITH traces AS (
SELECT tr.transaction_hash, tr.gas_used, SUBSTRING(tr.input, 0, 10) as sig_hash
FROM `bigquery-public-data.crypto_ethereum.traces` tr --tracks internal transactions
WHERE tr.to_address = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' -- WETH
AND tr.status = 1
AND tr.call_type = 'call'
),

txs AS (
SELECT tr.sig_hash, (tx.gas_price)/1e9 as gwei
FROM `bigquery-public-data.crypto_ethereum.transactions` as tx
JOIN traces tr 
ON tr.transaction_hash = tx.hash
)

SELECT sig_hash, SUM(gwei) as gwei, COUNT(gwei) FROM txs
GROUP BY sig_hash

I didn't think that this query would also return the calls for totalSupply() decimals(), etc. In future queries, I'll make sure to filter these out!

The script get_gas_saving.py iterates through the functions above and sums the gas saved for each function. The output is as follows:

Running 'scripts/get_gas_saving.py::main'...
970677015.604 Gwei/gas spent on performing transferFrom(). Each function saves 42 gas
Total ETH saved by transferFrom(): 40.768
--
8570367257.755 Gwei/gas spent on performing transfer(). Each function saves 70 gas
Total ETH saved by transfer(): 599.926
--
311808609.989 Gwei/gas spent on performing approve(). Each function saves 46 gas
Total ETH saved by approve(): 14.343
--
Total ETH saving across all functions: 655.037 
Total number of transactions: 91959066

Discussion

A cursory glance at the etherscan page shows that the WETH contract has 432.162 WETH lost within the contract, which is lower than the 655 ETH saved in gas fees.
The conclusion that I would draw here is that users of a smart contract would end up overpaying for the additional sanity checks discussed above.

It may be interesting to perform the same query looking at all ERC20 tokens, rather than just WETH to see whether the same conclusion is valid across all ERC20s or just WETH.

However, another argument could be made that it doesn't really make too much of a difference given the sheer number of transactions that have taken place. The difference of 223 ETH, when amortised over 92 million transactions is a value of 0.0000024 ETH per transaction.

After I published this post, I spoke with a few other people. One interesting perspective was that the additional sanity checks act as a form of socialised insurance. I personally do not mind paying fractionally more on a transaction, knowing that it might prevent somebody else from losing their funds.