Unstructured thots on writing a smolbrain three-way market in Solidity (learning journal)
Quick post to share some learning notes about a smol contract I wrote over the past few days.
villager
villager
is a collection of smart contracts that aims to create a permissionless way for multiple parties to exchange tokens at a predetermined rate. This was a useful exercise to learn some Solidity and figure out things like interfacing for ERC20 tokens or using Foundry. The aim was to build a system for three tokens: food
, wood
and coin
to be exchanged in a contract at a rate set by an owner
as per the below
This is only ever likely useful in a very narrow set of scenarios, if ever:
Deep out of the money limit order
If the owner wants to create an effective 'limit order' for a token well in advance, or at a deep out of the money premium
Indirectly selling one token for another
If the owner wants to buy and sell tokens indirectly, i.e. issue wood
without selling it directly for anything in particular but being open to receiving a minimum amount of food
or coin
Buyer of last resort for a token at a fixed discount
If the owner wants to guarantee the exchange of, say, coin
at a fixed discount from the market
There are probably a million reasons why this is the dumbest way to do either of these three things but I just wanted to practice.
Tooling and testing
I used Foundry, a Rust framework for developing Solidity smart contracts that's extremely convenient. Writing tests and developing are all done in Solidity so there is no need to jump back and forth between other languages and frameworks.
It also has a number of really useful features for testing that helped me a lot:
assertEq(address(three.getFood()), address(food));
asserts and other testing functions to double check functions are doing what you want them to dovm.prank(alice);
pranking the simulated vm so you can simulate what happens if one user or another trigger a functiondeal(address(food), address(alice), 1_000 * WAD);
dealing fake ERC20 tokens in testing so you can try out integration functions that use ERC20 tokensI struggled a bit to get this to work as there is more than one implementation of
deal
in the Foundry librariesThe way that I managed to get it to work is by importing the Std-Utils library and instantiating three fake tokens in test:
import "forge-std/StdUtils.sol";
import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol";
...
ERC20 food = new ERC20("Food", "FOOD");
Function development
I had mapped out how I imagined the contract would function, which is basically as a cross-rates table that allows the contract to exchange tokens at. The contract itself must have one of the tokens to begin with. The owner would presumably be in charge of 'seeding' it.
The owner would also be in charge of setting two of the 6 cross-rates and the others would be derived from it:
Units of A in terms of B: food
wood
coin
food
-
200.0000
1.0256
wood
0.0050
-
0.0051
coin
0.9750
195.0000
-
It was at this point that I realized what it meant in practice that Solidity does not do floating point numbers and can therefore only do integer division.
Integer division means that you can divide 10 by 10 and get 1 but if you divide 1 by 2 you get 0 and if you divide 7 by 5 you get 1, as per this example. The EVM stack only accepts integers, so if you want to get a rate like 0.0050 you need to express it as some very large integer, and then account for the zeros off-chain. Leading to my discovery of the utility of 18 decimal places. I did say this was a smolbrain learning journal.
I had to therefore figure out what to do in a few edge cases: what if you divide 5 by 7 but the cross-rate itself wouldn't necessarily mean that the value of tokens exchanged is zero?
The smolbrain solution is to double up on decimal places during any integer divisions, and just remember the zeros to unwind it or while multiplying it out. Given the amount of time I have spent reading MakerDAO contracts I set a constant for 10^18
called WAD
. For example,
function farm(Resource name_, uint256 amount_) public {
wood.transferFrom(msg.sender, address(this), amount_);
food.transfer(msg.sender, (amount_ * WAD) / woodFood);
emit Farm(msg.sender, (amount_ * WAD) / woodFood, Resource.WOOD);
}
Would be the way to get the cross-rate that divides by the woodFood
rate. You receive a 10^18 amount_
, you have a 10^18 woodFood
rate, so to come back dimensionally to 10^18
for the output you multiply by WAD=10^18
.
This still creates some weird edge cases. For example, in testing I found that setting the two base rates at 3 and 5 created a situation where exchanging 3 coin
for the expected 5 wood
actually gave me 2 wei
less than I expected because of an integer division issue. I think? I forced the test to say a wrong thing to get it to pass. I am sure this is not amazing practice.
uint256 woodFood = 5 * WAD;
uint256 coinFood = 3 * WAD;
coin.approve(address(three), 3 * WAD);
three.chop(Three.Resource.COIN, 3 * WAD);
assertEq(wood.balanceOf(address(charlie)), 4_999_999_999_999_999_998);
The other edge case is for very small numbers, such as 10 wei
. At such small scales, there is no point to decimal precision and you start to get really large rounding errors as a % of the value. The easiest thing is to force the functions not to work if it ever looks like they will exchange for 0 tokens
.
Quitting and permissionless contracts
The cooler things about permissionless finance are designs that allow participants to interact with one another through the conduit of predetermined rules set by a smart contract. When someone opens a vault on MakerDAO, they are pushing buttons with very clear rules and very complex functions can be executed to transact without any intermediaries.
Permissionless contracts tend to have funny features, such as just sitting around and not doing anything until someone (anyone) triggers a function. The protocols might create incentive structures to incite people to actually trigger them with bots. Many of these functions are knock-on triggered by other function calls.
In order to prevent tokens from getting stuck on the contract, there has to be a way of sweeping the balance out somewhere. The design anticipates the existence of an owner
address. This address has the right to set the rates and quit()
the tokens. Whenever there is a >0 balance of either of the three tokens, if the owner
calls quit()
, it will receive the balance of each of the tokens.
Porque
I like understanding how things work. I am amazed at every turn at the minimalist elegance of systems that can execute such complex transfers of value with no intermediary. Mind you, many (most?) banks continue to run off COBOL datacenters and the population of engineers who can maintain these is literally dying of old age.
Writing Solidity is tricky but not as syntactically complicated as other languages I have experienced. Foundry helps a lot to make things a lot easier. But it is really hard to write things well and I constantly feel like there is a smarter way to do what I am trying to accomplish, without success. I am sure there are many patterns to do any number of the things I did better. If anyone has the time or disposition to help me figure out what those things are, I'd be highly appreciative of their feedback.
Thank you for coming to my TED Talk.