Math in Solidity (Part 4: Compound Interest)

This article is the fourth one in a series of articles about doing math in Solidity. This time the topic is: compound interest.

Introduction

In our previous article we talked about percents and how they are calculated in Solidity. In financial math percents are usually associated with interest being paid on loans and deposits. At the end of every time period, say one month or one year, a certain percent of the principal amount is paid to the lender or deposit holder. This schema is known as simple interest, and the percentage paid per period is known as periodic interest rate.

In computer programs, it is common to use interest ratio instead of interest rate. For example, for 3% interest rate the ratio is 0.03. Thus, interest payment amount for a period may be calculated as interest ratio multiplied by principal amount, and from the previous article we already know how to do this in Solidity effectively and precisely.

Simple interest schema is simple, but things become more complicated if interest is not paid immediately to the lender or deposit holder, but rather added to the principal amount. In such case, the interest accumulated during past periods affects the amount of interest be collected in the future.

In this article we discuss how this schema could be implemented in Solidity, and the name of this schema is: compound interest.

Periodic Compounding

We already know how to calculate simple interest. The straightforward solution for calculating compound interest is to calculate simple interest at the end of each time period, and then add this calculated interest to the principal amount. In high level language, such as JavaScript, this it look like this:

principal += ratio * principal; // Do after each time period

Here ratio is almost definitely a fractional number, but Solidity doesn’t support fractions, so in Solidity we should write something like this:

principal += mulDiv (ratio, principal, 10^18);

We use mulDiv function from the previous article and assume that ratio is a fixed-point number with 18 decimals after the dot.

The code above will work in most cases, but its += operation may overflow, so to make the code secure we need to alter it like this:

principal = add (principal, mulDiv (ratio, principal, 10^18));

This variant is probably fine for production but is hard to read. In this article we will use plain arithmetic operations for simplicity, as if Solidity would support fractions and arithmetic operations would not overflow. In real code these operation sould be replaced with proper functions.

Once we know how to compound interest for a single period, the question is:

How Could We Trigger Compounding at The End Of Every Time Period?

Spoiler: we shouldn’t

Unlike traditional applications, smart contract cannot have any background activity. Contract’s bytecode is executed only when a transaction invokes the contract, either directtly of via another smart contracts. One may rely on third-party services such as Provable (formerly known as Oraclize) to call the particular smart contract on regular basis, or may economically motivate ordinary people to do so.

This approach works, but has a number of drawbacks. First, somebody has to pay for gas, so it is not free. Second, interest has to be compounded at the end of every period, even if nobody will access the updated principal amount during the following time period. Third, the shorter time period is, the more often compounding has to be executed, and thus the more gas is consumed. Forth, for short time periods this method is inaccurate, as transaction mining time in unpredictable and may be quite large at times of high network load.

So, if compounding at the end of each period is not a good idea for Solidity, then

When Should We Compound Interest?

Spoiler: “lazy” compounding

Instead of compounding interest at th end of every time period, the better way would be to compound only when somebody needs to access principal amount or the debt or deposit, and at this point perform compounding for all the time periods ended since the interest was compounded for the last time:

uint currentPeriod = block.timestamp / periodLength;

for (uint period = lastPeriod; period < currentPeriod; period++)

principal += ratio * principal;

lastPeriod = currentPeriod;

This code adds all not yet compounded interest to the principal amount and has to be executed every time somebody wants to access principal . Such approach is known as “lazy” compounding, and actual calculations are postponed until somebody really needs their result.

However, the implementation of “lazy” compounding shown above has one important problem. Actual gas consumption linearly depends on how many time intervals have passed since the last time interest compounding was performed. If time period is very short, or compounding was last performed long time ago, then gas amount needed to compound interest for all the passed time periods may exceed block gas limit, effectively making further interest compounding impossible. So the question is:

How to Do “Lazy” Compounding More Efficiently?

Spoiler: double the intervals

First of all we note, that componsing interest for a single tiem period may be rewritten like this:

principal *= 1 + ratio;

For the two time intervals, this would be:

principal *= (1 + ratio) * (1 + ratio);

Then we note, that (1+r)²=1+(2r+r²), so effective interest ratio for double time interval is 2r+r², where r is the interest ratio for single time interval. If the number of time intervals we want to compound interest for is even, we may half the number of time intervals by doubling time interval duration. When the number of time intervals is odd, we may just perform compounding once, and thus make the remaining number of time intervals to be even. Here is the code:

function compound (uint principal, uint ratio, uint n)

public pure returns (uint) {

while (n > 0) {

if (n % 2 == 1) {

principal += principal * ratio;

n -= 1;

} else {

ratio = 2 * ratio + ratio * ratio;

n /= 2;

}

} return principal;

}

The code above has logarithmic complexity and works well when principal and ratio are large, so principal * ratio product has enough significant decimals for decent precision. However, if, principal and ratio are small, this code above may produce inaccurate result. Now the question is:

How To Improve Precision of Lazy Compounding?

Spoiler: exponentiation by squaring

In the code shown above, precision is lost in the following lone:

principal += principal * ratio;

This is because we assume principal to be integer, so assignment has to round calculated value. The rounding may be performed multiple times, and rounding errors add up.

To address this issue we may note, that for n time intervals, interest may be compounded like this:

principal *= (1 + ratio) ** n;

This code would work is Solidity had support for fractions, but as long as it hasn’t, we need to timplement exponentiation ourselves. We use the same approach of logarithmic complexity, as we used in the previous section, so the code is very similar:

function pow (uint x, uint n)

public pure returns (uint r) {

r = 1.0; while (n > 0) {

if (n % 2 == 1) {

r *= x;

n -= 1;

} else {

x *= x;

n /= 2;

}

}

} function compound (uint principal, uint ratio, uint n)

public pure returns (uint) {

return principal * pow (1 + ratio, n);

}

Note that expression: r = 1.0 . It is here to remember that we are working with fractions here as if Solidity does support them, while it actually doesn’t. One will have to replace all arithmetic operation with functions that implement fraction math. For example, here is how the real code will look using ABDK Math 64.64 library, that implements arithmetic operations for 64.64-bit fixed-point numbers:

function pow (int128 x, uint n)

public pure returns (int128 r) {

r = ABDKMath64x64.fromUInt (1); while (n > 0) {

if (n % 2 == 1) {

r = ABDKMath64x64.mul (r, x);

n -= 1;

} else {

x = ABDKMath64x64.mul (x, x);

n /= 2;

}

}

} function compound (uint principal, uint ratio, uint n)

public pure returns (uint) {

return ABDKMath64x64.mulu (

pow (

ABDKMath64x64.add (

ABDKMath64x64.fromUInt (1),

ABDKMath64x64.divu (

ratio,

10**18)),

n),

principal);

}

Actually, this library already has pow function, that can be used instead of our implementation.

The code above is quite precise and straightforward, but it works for discrete time intervals only. What if we need to compound interest for arbitrary time intervals? Such schema is known as

Continuous Compounding

The idea of continuous compounding is to calculate interest for arbitrary, rather than fixed, periods of time. One way to achieve this is to use fractional number of periods. We already know how to calculate compound interest for n periods:

principal *= (1 + ratio) ** n;

Lets assume that time period is one year, and we want to calculate compound interest for 1 month, i.e. for 1/12 of the year. Then the formula should be:

principal *= (1 + ratio) ** (1 / 12);

Unfortunately, neither Solidity, nor pow function presented above support fractional exponents. We could implement them either via integer power and root, or via fixed-base logarithm and exponent, but

Is There Simpler Way to Do Continuous Compounding?

Spoiler: yes: don’t do it

Time in the real world is continuous, or at least it seems to be so. Time in Etherem is discrete. It is measured in seconds and is represented by integer numbers. So periodic compounding with 1 second period works as continuous compounding, as nobody may observe principal value in the middle of a period.

The idea to compound interest every second may look weird at the first glance, but on Ethereum it works surprisingly well. Annual interest rate of 3% is effectively equivalent to per-second rate of 0.000000093668115524%, or 0.000000000936681155 per-second interest ratio represented with 18 decimals. Here we assume 1 year to have 31556952 seconds.

Compounded for 1 year (31556952 periods) using the function presented above, this ratio gives 2.99999999895% annual rate, so almost 10 significant digits of precision. Quite enough for most applications. Using 128.128-bit fixed point numbers instead of 64.64-bit, or even floating point could allow achieving even higher precision.

In our experiments compounding periodic per-second interest for 1 year consumed about 90K gas. This is probably affordable for most applications, but in general is quite high. In our next article we will present cheaper approach offering about the same precision.

Conclusion

Complicated fractional calculations, such as those, required for compounding periodic interest rates, could be challenging in Solidity due to lack of native fraction numbers support.

However, compound interest could still be efficiently calculated using exponentiation by squaring algorithm and emulated fixed-point numbers.

Suggested approach is powerful enough to compound per-second interest rates on 1 year (and even longer) time spans. However this approach is quite gas consuming.

In out next article we will present better approach, and the next topic will be: exponent and logarithm.