Deciphering User-Defined Operators in Solidity

An effective guide to understanding User-Defined Operators in Solidity and how they work

Deciphering User-Defined Operators in Solidity

Solidity 0.8.19 came up with quite a few changes and upgrades.
In this post, we will delve deeper into user-defined operators for user-defined value types.

✍🏻
I will refer to them as the following in the rest of the article:
a. User-Defined Operators - UDOs
b. User-Defined Value Types - UDVTs

Before learning about UDOs, we need to look into UDVTs.

What are User-Defined Value Types

UDVTs are gasless abstractions of value types over the pre-defined value types in solidity. This was introduced in solidity 0.8.8.
In simpler terms, we can call them aliases for other value types. The core reason behind introducing this was to facilitate more strict definitions of variables.

Quick Example

For example our contract stores two types of addresses, one for buyers and while the other for sellers. We already know that under the hood both of these variables will be of type address, however

  • what if we want to make them different to avoid any confusion or intermingling of different concepts?
  • What if we want the data types to be more descriptive about the value it’s storing?

Before UDVTs were introduced, this goal was attained using structs. Below is an example.

Using structs to define more descriptive data types (Costs gas)

The four functions in this code are used to wrap or unwrap the given data type into or from the defined data type.

I am sure you have seen this type of usage in some contracts. However, as we know structs are reference types , they’ll use memory, costing more gas than just using uint. This is where UDVTs are being used. They are simple extracts which doesn’t cost gas.

Now as far as the syntax is concerned, we can define UDVTs like type A is B where A is the extracted value type, can also call an alias for B which is the underlying type which can be uint, address , etc. Once we declare these value types we get two attached methods with them, wrap and unwrap , A.wrap(value) can be used to convert an underlying type to the newly created value type, A.unwrap(value) is used for the vice versa.

Let’s see this in code.

Using UDVTs to define more descriptive data types (Zero Cost)

This was a demonstration related to the previous code snippet, however, you might have realized that we don’t need to define the wrapping/unwrapping functions explicitly because solidity literally gives wrap/unwrap functions. So if we talk about the usage of these functions below is another code snippet.

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.8;

// Represent a 18 decimal, 256 bit wide fixed point type
// using a user defined value type.
type UFixed is uint256;

/// A minimal library to do fixed point operations on UFixed.

    uint256 constant multiplier = 10**18;

    /// Adds two UFixed numbers. Reverts on overflow,
    /// relying on checked arithmetic on uint256.
    function add(UFixed a, UFixed b) internal pure returns (UFixed) {
        return UFixed.wrap(UFixed.unwrap(a) + UFixed.unwrap(b));
    }

    /// Multiplies UFixed and uint256. Reverts on overflow,
    /// relying on checked arithmetic on uint256.
    function mul(UFixed a, uint256 b) internal pure returns (UFixed) {
        return UFixed.wrap(UFixed.unwrap(a) * b);
    }

    /// Take the floor of a UFixed number.
    /// @return the largest integer that does not exceed `a`.
    function floor(UFixed a) internal pure returns (uint256) {
        return UFixed.unwrap(a) / multiplier;
    }

    /// Turns a uint256 into a UFixed of the same value.
    /// Reverts if the integer is too large.
    function toUFixed(uint256 a) internal pure returns (UFixed) {
        return UFixed.wrap(a * multiplier);
    }
}

We can conclude that UDVTs are not something that affects the logic of the contract but a kind of syntactical sugar that makes our code clearer and readable. PRB math library for example, which is a library used to tackle the floating number issue in solidity, uses UDVTs to define different types of floating numbers.

Now the problem arises when we try to use arithmetic operations on UDVTs. As you can see in the above example, we have to unwrap the passed variables in order to use built-in operators. This is where UDOs are introduced.

What are User Defined Operators?

UDOs are built using the two built-in features of solidity i.e. built-in operators and using … for ….

UDOs are basically an extended version of using for.  To recall, using for is used to:

  1. Attaching all Library functions to a data type.
    using LibrayName for TypeName
  2. Attaching library functions to all the data types.
    using LibrayName for *
  3. Attaching specific library functions, or free functions to any specific type.
    using {LibrayName.FunctionName, FreeFunctionName} for TypeName

Now UDO comes into play and defines the fourth type of using for statement.

Something like this 👇:

using {FunctionName as OperatorSign} for UDVTName global;

This syntax facilitates attaching a function as an operator sign, the same as attaching a library function, the difference is we can use an operator sign instead of using the default way of calling the function. This will be clearer by looking at the code example below.

There are some rules while using User Defined Operators.

  • They can only be defined as free functions(functions defined at the file level).
  • The free functions should be pure.
  • Can only be defined at global using for directive, i.e. it can not be inside a contract but at a file level.
  • User-defined Operator can only be attached to the UDVTs, and not to the underlying default value types.
  • They can only be defined for a particular type, i.e. they won’t work with different UDVTs in the same function.
  • At last, while attaching the operators to any UDVT, only these operator signs can be used: &, |, ^, ~, +, -, *, /, %, ==, !=, <, <=, >, >=.

Let’s now look at a code example.

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

//The types are defined at the file level
type Float is uint256;
type unFloat is uint256;

//"using for" statement are written at global directive
//not inside a contract
using {add as +} for Float global;
using {multiply as *} for Float global;
using {divide as /} for Float global;

//These are pure free functions, which is a requirement
///@notice Each function works with only one type. 
    function add(Float a, Float b) pure returns (Float) {
        return Float.wrap(Float.unwrap(a) + Float.unwrap(b));
    }

    function multiply(Float a, Float b) pure returns (Float) {
        return Float.wrap(Float.unwrap(a) * Float.unwrap(b));

    }

    function divide(Float a, Float b) pure returns (Float) {
        return Float.wrap(Float.unwrap(a) / Float.unwrap(b));

    }

//Using the attached operators inside a contract
contract UDO {
    
    Float cent = Float.wrap(100);
    Float decimal = Float.wrap(1e18);

 //The multiplication and division using operators is only possible
 // because we attached these particular operators sign to the relavant functions
    function takePercent(Float _amount, Float totalAmount)
        external
        view
        returns (Float)
    {
        return (_amount * cent * decimal)/(totalAmount);

    }
}

Attaching any function to any UDVT is independent of binding the same function to it. This means that we can’t call the free function with UDVTs in a similar fashion as a library function like add(a,b)or a.add(b) when we are binding them as an operator, but obviously, we can do so when they are attached as functions.
Let’s look at the example below.

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

type Float is uint256;
type unFloat is uint256;

//      binding and attaching
using {multiply as *,multiply} for Float global;
using {divide as /} for Float global;

    function multiply(Float a, Float b) pure returns (Float) {
        return Float.wrap(Float.unwrap(a) * Float.unwrap(b));
    }

    function divide(Float a, Float b) pure returns (Float) {
      return Float.wrap(Float.unwrap(a) / Float.unwrap(b));
    }

contract UDO {
    
    Float cent = Float.wrap(100);
    Float decimal = Float.wrap(1e18)

    function takePercent(Float _amount, Float totalAmount)
        external
        view
        returns (Float)
    {
        // return (_amount * cent * decimal)/(totalAmount);
        return      (_amount.multiply(decimal.multiply(cent))).divide(totalAmount);
       //In this line Multiply will work but divide will throw an error, because 
       // we haven't binded the divide function to Float.    
}
}

The signs we used for particular functions are not strictly needed, we could’ve used / instead of + with the add function, and the / would work as the add function. However, that will cause unwanted confusion so we won’t do that.

What are the use cases?

As discussed earlier the UDVTs can be used to prevent any kind of type mistakes and provide better understanding of the logic, UDOs increase the usability of the same by making it more accessible an familiar.

The best usage of these are math libraries, where we can have multiple type of floating numbers and much more.

That’s it for this article, join us with all your solidity questions/confusions HERE.

Join Decipher with Zaryab today

Let's learn and build better, secure Smart Contracts

Subscribe Now