Creating an Off-chain Uniswap Pricing Oracle by Porting Solidity Arithmetic
TL;DR: If you follow along with this post, you’ll get a recipe for creating an offline pricing oracle for Uniswap by reimplementing several library functions. We start with the high level solidity functions and drill down into the guts of Yul and Ethereum.
Let’s say, hypothetically, you wanted to create an off-chain pricing oracle for Uniswap, a decentralized ERC20 token exchange. You might notice that the Uniswap V2 Router02 contract offers many ways to purchase tokens, but assumes you have already calculated the price they are trading for. It doesn’t offer an easy way to query this price. This is confirmed by checking the Pricing documentation, “in Uniswap V2, trades must be priced in the periphery. The good news is that the library provides a variety of functions designed to make this quite simple.” Fortunately, Uniswap does provide us with an example pricing oracle. Unfortunately, it’s provided as a smart contract. If we want to determine pricing off-chain, we’ll have to reimplement the pricing oracle. And down the Solidity rabbithole we go.
The Example Oracle
If we try to implement the math as shown in the documentation, we’ll quickly
realize we’re missing a few elements (such as previous cumulative prices).
Luckily, Uniswap was kind enough to provide us with an
example pricing oracle.
The example oracle computes price averages for tokens in a pair with the
update()
function. Let’s take a look at that function.
function update() external {
(uint price0Cumulative, uint price1Cumulative, uint32 blockTimestamp) =
UniswapV2OracleLibrary.currentCumulativePrices(address(pair));
uint32 timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired
// ensure that at least one full period has passed since the last update
require(timeElapsed >= PERIOD, 'ExampleOracleSimple: PERIOD_NOT_ELAPSED');
// overflow is desired, casting never truncates
// cumulative price is in (uq112x112 price * seconds) units so we simply wrap it after division by time elapsed
price0Average = FixedPoint.uq112x112(uint224((price0Cumulative - price0CumulativeLast) / timeElapsed));
price1Average = FixedPoint.uq112x112(uint224((price1Cumulative - price1CumulativeLast) / timeElapsed));
price0CumulativeLast = price0Cumulative;
price1CumulativeLast = price1Cumulative;
blockTimestampLast = blockTimestamp;
}
The first thing you’ll notice is that it gets a few values, price0Cumulative
,
price1Cumulative
, and blockTimestamp
, from a provided Oracle library.
The Pricing
page covers what this means, but in summary the cumulative price is a function
of existing reserves and current block timestamps. We’ll see how it’s calculated
later in the Oracle Library section.
Then the example oracle uses these cumulative prices, and how they’ve changed over time,
to compute the price average as a uq112x112
fixed point number (more on
that in a bit). If enough time hasn’t passed (e.g., if timeElapsed
is 0
and
the prices haven’t changed) then we skip recalculating the average.
One interesting comment here is where it says overflow is desired. This means if we want to replicate the pricing oracle offline, we also need to replicate overflow of 224-bit integers as it occurs in the EVM (or does it?).
Fixed Point numbers
As a quick aside, let’s look at what fixed point numbers are. If you’re familiar with floating point number representations, like IEEE 754, it might seem a bit strange at first. The wikipedia page covers it well but in short it’s a way of representing fractional numbers where the number of bits before and after the decimal is fixed instead of floating. We don’t get to specify exponents like with floating point numbers, and this reduces the range a bit, but it’s also simple to work with and, importantly, can be implemented with integers on systems with no hardware support for floating point numbers. This is important because Solidity does not natively support floating point numbers. If we want to easily work with fractions in the Solidity, fixed point arithmetic is a solid choice.
The Oracle Library
With that out of the way, let’s take a look at that Oracle Library.
The library is pretty simple, with only one function: currentCumulativePrices
,
replicated here. If we want to implement the pricing oracle offline, we will
need to replicate this functionality as well.
// produces the cumulative price using counterfactuals to save gas and avoid a call to sync.
function currentCumulativePrices(
address pair
) internal view returns (uint price0Cumulative, uint price1Cumulative, uint32 blockTimestamp) {
blockTimestamp = currentBlockTimestamp();
price0Cumulative = IUniswapV2Pair(pair).price0CumulativeLast();
price1Cumulative = IUniswapV2Pair(pair).price1CumulativeLast();
// if time has elapsed since the last update on the pair, mock the accumulated price values
(uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast) = IUniswapV2Pair(pair).getReserves();
if (blockTimestampLast != blockTimestamp) {
// subtraction overflow is desired
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
// addition overflow is desired
// counterfactual
price0Cumulative += uint(FixedPoint.fraction(reserve1, reserve0)._x) * timeElapsed;
// counterfactual
price1Cumulative += uint(FixedPoint.fraction(reserve0, reserve1)._x) * timeElapsed;
}
}
If you read the pricing documentation, this math should be familiar to you. We take the previously computed cumulative prices (saved somewhere, in this case on-chain), get the current reserves of our tokens, and update the cumulative price based on the ratio between reserves and the time elapsed.
At this point we can’t go any further without looking into this FixedPoint library and seeing how these fixed point numbers and fixed point arithmetic are implemented. If we want to replicate the oracle, we need to replicate the fixed point math.
The Fixed Point Library
The Fixed Point library
can be found in the uniswap-lib
repository. The library is long enough that
I won’t copy the whole thing here, but let’s review some relevant sections.
First, let’s see what that uq112x112
is under the hood.
// range: [0, 2**112 - 1]
// resolution: 1 / 2**112
struct uq112x112 {
uint224 _x;
}
We can see it’s just a struct that wraps around a uint224
with comments
defining it’s range and resolution. As far as I can tell, it’s
used to enforce using uint224
with functions in this library that expect
a uq112x112
fixed point number without type confusion. We can see functions
for adding, multiplying, subtracting, and so on, on these fixed point
numbers.
That covers FixedPoint.uq112x112
, now we need to take a look at the
FixedPoint.fraction()
function.
// returns a UQ112x112 which represents the ratio of the numerator to the denominator
// can be lossy
function fraction(uint256 numerator, uint256 denominator) internal pure returns (uq112x112 memory) {
require(denominator > 0, 'FixedPoint::fraction: division by zero');
if (numerator == 0) return FixedPoint.uq112x112(0);
if (numerator <= uint144(-1)) {
uint256 result = (numerator << RESOLUTION) / denominator;
require(result <= uint224(-1), 'FixedPoint::fraction: overflow');
return uq112x112(uint224(result));
} else {
uint256 result = FullMath.mulDiv(numerator, Q112, denominator);
require(result <= uint224(-1), 'FixedPoint::fraction: overflow');
return uq112x112(uint224(result));
}
}
For the most part we can implement all this functionality by only
porting the FixedMath library, with one exception. If the numerator value
is greater than $2^{145}-1$ then we need to call FullMath.muldiv()
. What
is that, exactly?
The FullMath Library
FullMath.sol
is another Uniswap provided library that, as far as I can tell, calculates
proportionality between two numbers in Solidity without running into issues stemming
from overflows and large numbers. Fairly straightforward, and not difficult
to port from Solidity to your language of choice.
However, in the first line of the fullMul()
function you’ll see the following:
uint256 mm = mulmod(x, y, uint256(-1));
What is this mulmod()
function, and where is it coming from? Remember how
we’re using fixed point arithmetic because solidity doesn’t support floats?
Well, here we’re using a special arithmetic function because the EVM
doesn’t actually support 256-bit mathematics (I’m simplifying). The EVM
needs to run on real hardware, and real hardware mostly only supports
32-bit or 64-bit math (“What about SIMD? Also, the DEC VAX supported 128-bit integers!” “Okay,
you’re very smart. But also it did this by using four consecutive 32-bit
registers.”). Under the hood, solidity relies on several libraries and functions
that take the real 64-bit and 32-bit registers and use them to behave as
though you’re interfacing with a 256-bit register (or 144, or 512, and
so on).
If we want to make our pricing oracle, we need to support this function.
Yul, Wasm, and Ewasm
Before we go any further, we need to talk about Yul.
Yul (previously also called JULIA or IULIA) is an intermediate language that can be compiled to bytecode for different backends.
Support for EVM 1.0, EVM 1.5 and Ewasm is planned, and it is designed to be a usable common denominator of all three platforms. It can already be used in stand-alone mode and for “inline assembly” inside Solidity and there is an experimental implementation of the Solidity compiler that uses Yul as an intermediate language. Yul is a good target for high-level optimisation stages that can benefit all target platforms equally.
In short, Yul is wasm-like language used to write pseudo-asm for low level libraries and functions running on the EVM, like the arithmetic libraries used to emulate 256-bit math. This is important because when we get down into specific Yul functions, we’ll find it relies on WebAssembly primitives. If we want to reimplement those, we’ll need to refer to the wasm documentation.
Ewasm, or Ethereum WebAssembly, is an ongoing project to migrate the EVM backend to a web assembly platform instead of the existing platform. For now it appears that Yul is compiled directly into EVM platform-specific bytecode, but in the future it will be compiled down to ewasm. One advantage of the ewasm migration is that in the future “it should theoretically be possible to write smart contracts in any language that compiles into WebAssembly.” Since Yul already closely resembles wasm, compiling it to an ewasm EVM backend should be fairly straightforward. There are many other advantages covered in the link above. There are concessions that need to be made for a blockchain platform, hence ewasm instead of regular wasm.
If you’re unfamiliar, wasm stands for WebAssembly. WebAssembly is fast, safe, stack machine being developed to improve and extend the performance and capabilities of the internet. It’s really cool, check out my Game of Life, written in Rust, compiled to wasm running on my site.
EVM Math
Okay, back to mulmod()
.
We find the relevant code in Arithmetic.yul
under the libyul solidity backends.
Here’s the mulmod()
function, in Yul, which as you can see requires
mul_256x256_512()
and mod512()
functions (also in Arithmetic.yul) to
work.
function mulmod(x1, x2, x3, x4, y1, y2, y3, y4, m1, m2, m3, m4) -> z1, z2, z3, z4 {
let r1, r2, r3, r4, r5, r6, r7, r8 := mul_256x256_512(x1, x2, x3, x4, y1, y2, y3, y4)
let t1
let t2
let t3
let t4
t1, t2, t3, t4, z1, z2, z3, z4 := mod512(r1, r2, r3, r4, r5, r6, r7, r8, 0, 0, 0, 0, m1, m2, m3, m4)
}
Each of those values x1
, x2
, and so on, is a 64-bit unsigned integer.
To make a long story short, if we go through the functions called by mulmod()
we’ll see that we
need to implement the following Yul functions if we want to
replicate the pricing oracle exactly:
mulmod()
gte_512x512()
shr512_internal()
shl512_internal()
mod512()
sub512()
or_bool_512()
split()
mul_64x64_128()
mul_128x128_256()
mul_256x256_512()
add_carry()
i64.shl()
(from wasm)i64.shr()
(from wasm)i64.or()
(from wasm)i64.add()
(from wasm)
Reimplementation
I will leave most of the reimplementation work as an exercise to the reader. The actual work at this stage if fairly rote. Here are a few examples of low-level functions reimplemented in Go. Note, I’m using an array of 8 bytes to represent a 64 bit integer, but you could just as easily use any other representation. This was just the first thing that came to mind.
func i64_shl_u(x [8]byte) [8]byte {
var y [8]byte
for i := 4; i < 8; i++ {
y[i] = byte(0x00)
}
for i := 0; i < 4; i++ {
y[i] = x[i+4]
}
return y
}
// ...
// skip ahead a bit
// ...
// multiplies two 256 bit values resulting in a 512 bit value split into eight 64 bit values
func mul_256x256_512(
x1 [8]byte, x2 [8]byte, x3 [8]byte, x4 [8]byte, y1 [8]byte, y2 [8]byte, y3 [8]byte, y4 [8]byte,
) ([8]byte, [8]byte, [8]byte, [8]byte, [8]byte, [8]byte, [8]byte, [8]byte) {
a1, a2, a3, a4 := mul_128x128_256(x1, x2, y1, y2)
b1, b2, b3, b4 := mul_128x128_256(x1, x2, y3, y4)
c1, c2, c3, c4 := mul_128x128_256(x3, x4, y1, y2)
d1, d2, d3, d4 := mul_128x128_256(x3, x4, y3, y4)
r8 := d4
r7 := d3
var carry1, carry2 [8]byte
r6, carry1 := add_carry(b4, c4, [8]byte{0})
r6, carry2 = add_carry(r6, d2, [8]byte{0})
r5, carry1 := add_carry(b3, c3, carry1)
r5, carry2 = add_carry(r5, d1, carry2)
r4, carry1 := add_carry(a4, b2, carry1)
r4, carry2 = add_carry(r4, c2, carry2)
r3, carry1 := add_carry(a3, b1, carry1)
r3, carry2 = add_carry(r3, c1, carry2)
r2, carry1 := add_carry(a2, carry1, carry2)
r1 := i64_add(a1, carry1)
return r1, r2, r3, r4, r5, r6, r7, r8
}
When all of the underlying Yul arithmetic is implemented, we can work our way
back up piece by piece to the pricing oracle. Once we have the pricing oracle
done, we can call the update()
function in a loop to keep prices up to date, and use
that offline oracle to make decisions about stuff without deploying a smart
contract or doing anything on-chain!