Project Walkthrough: A Simple Diamond Driven Brokered ERC20 Example with dApp
I've very recently started working on a redesign of the DEIT site (the umbrella I hack on experiments/side projects under). Rather than just shedding the commentary and aiming to condense the information a bit, it occurred to me... why does this experimental DLT "brand" not have any web3 features? That had to change, and also presented a chance to walk through developing, testing, and launching a set of Diamond contracts onto mainnet to show what a (hobbled) Diamond Driven Development workflow might look like from start to finish.
Current State
I've opted to build the site off the arisac/dapp-starter repo and have now jankily cobbled together my DEIT content alongside a working wagmi
implementation that shows/hides content depending on whether you're connected or not, using rainbowkit
for UI (quite slick, will definitely be using that more). I can see my account balance and sign messages, but that doesn't exactly show off anything, so now comes the fun part: developing the smart contracts and integrating the dApp with them.
The Contracts
For this first web3 enabled DEIT redesign the goal is simple, and has been done to death: another ERC20 token... yay!... :\ ... But since that on its own still doesn't make for much interaction from the dApp, which is what I'm really after, to make it at least a little more interesting the project will be broken up into 3 phases, each with separate blog posts and development iterations:
- Phase 1: capturing the development and release of an ERC20 token sold/minted via a Broker contract. The Broker contract will mint 2 tokens for each purchased, holding the second minted token in the Broker contract until phase 2 is released
- Phase 2: I'll walk through extending the Broker contract to integrate with Uniswap/Sushiswap, at which point 1/2 of the MATIC pooled and 100% of the pooled DEIT token will be sent to the LPs, and going forward any fixed price buys from the Broker contract will move funds in the same ratios (1/2 native and all of the minted DEIT) per-buy transaction. LP tokens will be held by the Brokerage contract until phase 3
- Phase 3: a simple Pool contract will be deployed, and then extended as a Vault contract protected by NFT keys supporting various lock conditions (I think, haven't fully modelled it so implementation is liable to change). The DEIT tokens pooled by the Brokerage contract will then be sent to the Pool, and the Uniswap/Sushiswap LP tokens previously held along with the new Pool LP tokens minted will then be sent to the Vault, and the Brokerage contract will then likely be deprecated, or pivot into a more generalized direction, providing a window into a complete SDLC for a multi-faceted (literally :P) project, even if it is an entirely contrived one.
The Giant Disclaimer
These contracts exist for the sake of themselves... to provide some content for a blog post, and to remake a simple portfolio site with Web3 features, nothing more. There is no value to them, will likely never be any value to them, and if/when the Pool & Vault are launched they shouldn't be treated as anything more than the science experiments they are.
Phase 1: The ERC20 & Brokerage Contracts
Hardhat Setup
While I have some refactoring I'd like to do on it, I'll be starting with my [diamond-hardhat](https://github.com/proggR/diamond-hardhat) repo since it has some tests/tasks setup that'll save me some time (note after the fact: it did not... refactor impending to correct that, more on that in the addendum). To get started I simply run:
cd ~/ethdev
git clone https://github.com/proggR/diamond-hardhat.git deit-contracts
cd deit-contracts
npm install
npx hardhat node --network hardhat
This will run through the tests for the contracts defined and spin up your Hardhat node, which I can see working in my dApp once I connect MetaMask to the Hardhat network. So far so good. I could just plug that UI into these contracts but I'll assume I have no contracts and am starting from scratch.
The Diamonds & ERC20Facet
For Phase 1, I'll need 2 Diamond contracts: 1 for the ERC20 token itself, and one for the Brokerage contract that will manage initial sales and temporary pooling of assets.
Before I dive into the Facets, I can create the necessary diamonds simply by extending the Diamond.sol
contract
import { Diamond } from "./Diamond.sol";
contract DEIT is Diamond {
constructor(address _contractOwner, address _diamondCutFacet)
payable Diamond(_contractOwner, _diamondCutFacet) {
}
}
That gives us 2 contracts that look like this, one for DEIT and the other for DEITBroker. Simple. Worth noting, the constructor could be omitted entirely but I like to include it in case I end up deciding to break standard and tossing state into my proxy... which I haven't since there's no reason to, but still do it out of habit. Also worth noting, these contracts could themselves be entirely omitted, only ever using the single Diamond contract that's deployed multiple times, but again... old habits, so I tend to name my diamonds even just for personal clarity later on during testing/deployment since sharing the Diamond contract code wouldn't reduce the number of deployed Diamond contracts anyway.
Planning out Facets takes a bit more thought because you have 2 key considerations to keep in mind with Diamond Driven Development: your state management, and function naming conventions to avoid collisions.
Fortunately, the ERC20 token is fairly straight forward and will be made up of 2 Facets: 1 defining the standard ERC20 interface (+maxSupply), and the second defining the minting control logic that binds the ERC20 contract to the Brokerage contract.
For the Brokerage contract, I won't be using a standard and will instead need to develop the internal logic myself. This will require a few Facets to keep code clean and concerns separated. The first Facet will define the asset management/admin features of the Brokerage contract, including specifying the ERC20 contract being brokered and establishing the fixed cost against MATIC. The second Facet defines the purchase and minting logic I'm after most in Phase 1 to see the UI working. The third Facet will define any interactions affecting the amount of pooled assets, including admin withdrawal limits.
ERC20 Contract
This starter repo contains a MockERC20 token, but its opinionated and won't work as is. Copying it into a new ERC20Facet.sol
file, I've updated its init function to take in necessary params and have removed the AppStorageFacet
so I can focus on DiamondStorage throughout. I've also changed up how the storage contracts are referenced, adopting a Library pattern borrowed first from the devs at flair.dev but now I see is being employed commonly in most Diamond codebases.
The resulting ERC20Storage
Library looks as follows:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.12;
library ERC20Storage {
struct Schema {
string _name;
string _symbol;
uint8 _decimals;
uint256 _totalSupply;
uint256 _maxSupply;
mapping(address => uint256) _balances;
mapping(address => mapping(address => uint256)) _allowances;
}
function schema() internal pure returns (Schema storage ds) {
bytes32 position = keccak256("simple.erc20.diamond.storage");
assembly {
ds.slot := position
}
}
}
The generally adopted pattern is Layout/layout because you're describing the "storage layout", so you should definitely prefer that... but here I've gone for Schema/schema because... I just felt like it.
Using this Library from within the new ERC20Facet
then looks like:
import "../libraries/LibDiamond.sol";
import "../storage/libraries/ERC20Storage.sol";
import "../interfaces/IERC20.sol";
contract ERC20Facet is IERC20 {
modifier onlyOwner() {
LibDiamond.enforceIsContractOwner();
_;
}
function erc20Init(string memory name_, string memory symbol_, uint8 decimals_, uint256 totalSupply_, uint256 maxSupply_, address mintTo_) external onlyOwner returns (bool){
ERC20Storage.Schema storage _ds = ERC20Storage.schema();
_ds._name = name_;
_ds._symbol = symbol_;
_ds._decimals = decimals_;
_ds._totalSupply = totalSupply_ * 10**_ds._decimals;
_ds._maxSupply = maxSupply_ * 10**_ds._decimals;
_ds._balances[mintTo_] = _ds._totalSupply;
return true;
}
function symbol() public view virtual returns (string memory) {
return ERC20Storage.schema()._symbol;
}
function name() public view virtual returns (string memory) {
return ERC20Storage.schema()._name;
}
[...]
Throughout the ERC20 implementation the same pattern of either assigning the return of ERC20Storage.schema()
to a ERC20Storage.Schema
storage variable when multiple actions are expected or using its return directly is utilized, making for easy to understand code that avoids naming collisions, even in complex Facets requiring multiple DiamondStorage references.
ERC20 Facet Tests
This Facet still contains an internal minting function I'll want to segregate into a separate Facet and make private, but its in a state worth rolling the tests to play with it. Fortunately this starter repo already contains tests for the MockERC20Facet, and from the scripts' perspective not a lot has changed. Unfortunately this is also where the bulk of the not yet done refactoring in this repo lives... so it will require some change. Most of this change is tech debt not really relevant to this article, but may be captured in a followup as the starter repo is refactored/cleaned up.
Once the tests are setup and compiled with npx hardhat compile
, I simply spin up the Hardhat node again with npx hardhat node --network hardhat
, run the test script with npx hardhat test
and if all goes well I should have the addresses for the diamond, as well as the ERC20 facet, which I do. I'll also need to update the erc20-balance task to reference the ERC20Facet
contract (ideally these would all just leverage the interface files/function sigs as required but that'll be for another day...).
It would appear that worked, and I can confirm that by calling npx hardhat erc20-balance --diamond
and passing it the address of my diamond and seeing I have the full balance as expected. This gives me the first hook for the dApp UI to work with.
dApp Token Balance & Transfer
The dApp starter repo I'm using uses wagmi
for simple wallet interactions, and already had the useBalance
hook in place for ETH balance. To see the UI interacting with the contract, copying the same pattern/code already used and adding a token
address in the useBalance
hook config object was all it took to see the balance showing up as expected.
// before return
const { data: tokenBalance, isLoading: isTokenBalanceLoading } = useBalance({
address: address,
token: '0xa513E6E4b8f2a923D98304ec87F64353C4D5C853',
})
// inside return
<dtd>DEIT Balance</dtd>
<dd className="break-all">
{isTokenBalanceLoading
? 'loading'
: tokenBalance ?
`${tokenBalance?.formatted} ${tokenBalance?.symbol}`
: 'n/a'}
</dd>
Since I'd already added a form for transferring from a token, before moving onto the Broker contract code I decided to finish up the ERC20 features and get that form actually transferring between wallets. Fortunately wagmi
makes that as easy as getting the balance was, and simply requires grabbing the ERC20Facet
abi from your smart contract project's artifacts file and copying it to the dApp project. The dApp starter repo already had an api
folder in src/pages
so I added deit.abi.ts
, exporting a function that returns my abi
const, and importing it as deitABI
in the relevant component. The included abi could likely be trimmed down a bit, but part of the benefit of thinking in Facets is... it also wouldn't be trimmed by that much given its already modular and only dealing with the public facing functions.
From there I just need to jump down to my Web3Matrix
component where wagmi is already working its magic, and before the return I need to add:
const { config, refetch, error } = usePrepareContractWrite({
address: '0xa513E6E4b8f2a923D98304ec87F64353C4D5C853',
abi: props.DEIT20,
functionName: 'transfer',
args: [transferParams.to, transferParams.amount],
})
const { write } = useContractWrite(config)
const handleTransferChange = event => {
setTransferParams({
...transferParams,
[event.target.name]:
event.target.name == 'amount' ? ethers.utils.parseEther(event.target.value) : event.target.value,
})
refetch()
}
Then with the addition of a button handler, upon pressing the button the UI now successfully calls transfer
on the token contract with write?.()
after a simple client side balance test and I can see my balance change as expected.
The Broker
ERC20Brokered Extension Facet
Now that the basic ERC20 features are both working and integrated with the dApp UI, its time to define the Broker contracts and how the Broker changes the ERC20 Diamond that until now is just a standard ERC20.
To the latter's end, I created a IERC20DelegatedMinting
interface which defines the functions mint(to_, amount_)
, minter(address)
and minter()
, and the event MinterChange
tracking the oldMinter
, newMinter
and the origin
account that initiated the change (ie: the owner).
Now I could extend this base facet and update the deployment scripts to deploy this Minter aware version, but instead to show the power of Facets a new ERC20DelegatedMintingFacet
is created, which implements just the required IERCDelegatedMinting
events, functions and state, which it can store in its own DiamondStorage slot. But since this will require access to the standard ERC20 state as well, I'll also need to import that storage library. I'll also use the LibDiamond
to check for ownership before changing the minter.
One last thing to do before I can test this extension is to move the _mint()
function from the existing ERC20 contract to this new DelegatedMinter facet. This will let me mint from this facet, and only this facet, ensuring minting is controlled by the now defined delegated minter logic.
With that now done, I should be able to simply compile the contracts with npx hardhat compile
, and once compiled I'll upgrade the ERC20Facet
with the facet-upgrade
task, and then use the facet-deploy
to deploy and cut the new ERC20DelegatedMintingFacet
by name and the diamond contract address. That's working as expected, but then... I decided I hate typing such a long contract name, and changed it to ERC20BrokeredFacet
, updating all other names accordingly, including the storage library. Fortunately given everything is based on function signatures, I don't have to concern myself with removing the old facet before adding the new, and can simply call the facet-upgrade
task passing it the diamond and new contract name, and it deploys the "new" contract, and replaces the previous facet with the newly named one since nothing about their function signatures changed, so to the Diamond/EVM it knows they are the same Facet and treats it no different than it would had I just made an update to the contract and cut the new version. Likewise given nothing changed about the internal structure of the DiamondStorage struct or its slot position, even though the library was renamed, they are the same thing. I didn't need to given the use of Schema
throughout, but worth noting this would have held true even had that struct name changed to BrokerLayout
or Bob
: so long as the internal order/data types are untouched, and the same slot position used, the name is irrelevant and your data integrity will be preserved through your upgrade.
That upgrade works as expected, confirmed by the newly added brokered-minter
task which returns the current minter.... the zero address. So now the ERC20 token is mintable by only the minter, which currently doesn't exist. Awesome... To fix that, I'll now need to start building up the second diamond contract: The Broker.
interface IERC20Brokered {
event Transfer(address indexed from, address indexed to, uint256 value);
event NewBroker(address indexed oldMinter, address indexed newMinter, address indexed origin);
function mint(address to, uint256 amount) external returns (bool);
function minter(address) external returns (bool);
function minter() external view returns (address);
}
struct Schema {
address _minter;
}
Broker Interfaces & Facets
As identified above, the first Facet I'll want to roll is the admin facet allowing the broker diamond to be bound to the token contract. For this, first I'll define the interface for this facet called IBrokerAdmin
defining a token(string memory, address, uint)
function (the uint is a denominator value relative MATIC), a token()
function returning the current brokered token address, and 2 events: BrokeredAsset(address asset, bool underBrokerage)
and AssetPrice(address,uint)
. Next I'll implement that in BrokerAdminFacet
, and create the BrokerAdminStorage
library, along with a struct tracking the _symbol
, _brokeredAsset
as an IERC20Brokered
type, and denominator_
used for cost calculations.
interface IBrokerAdmin {
event BrokeredAsset(address indexed asset_, bool indexed underBrokerage_);
event AssetPrice(address indexed asset_, uint256 indexed price_);
function token() external returns (address);
function token(string memory symbol_, address token_, uint256 denominator_) external returns (bool);
}
struct Schema {
string _symbol;
IERC20Brokered _brokeredToken;
uint256 _denominator;
}
Jumping straight in no brakes, next comes the IBrokerPublic
interface with the Bought(address,uint amount_, uint total_)
event, a buy(uint amount_) payable
function and a bought(address) returns (uint,uint)
function that returns the total assets bought and ETH (actually MATIC) spent amounts for an account. That's then implemented in BrokerPublicFacet
, leveraging the BrokerPublicStorage
to track the total values mapped to addresses, along with the pooled ETH/assets.
interface IBrokerPublic {
event Bought(address indexed buyer_, uint256 indexed amount_);
function buy(uint amount_) external payable returns (bool);
function bought(address buyer_) external returns (uint256, uint256);
function price() external returns (uint256);
function remaining() external returns (uint256);
}
struct Schema {
mapping(address=>uint256) _totalAssetBought;
mapping(address=>uint256) _totalETH;
uint256 _assetsPooled;
uint256 _ethPooled;
}
Since these contracts compile, the best way to see them deployed also happens to be the best way to see them work, and also just good practice: write your tests!
But before I dive too far into them, I want to see the ERC20 token properly tracking this Broker diamond. So rather than jumping completely into the Facet tests (which require some tech debt addressing, more on that at the end), a simple test deploying just the DEITBroker diamond contract gives me its address, which I can now pass to the DEIT Token's minter(address)
function using the brokered-minter-set
task, and now when I call minter()
with the brokered-minter
task rather than the zero address, it gives me the Broker diamond address like I want. Shwing. Nearly there.
With the tasks supporting the interfaces I've defined so far working as expected (the Broker contract returns its brokered asset address, and on buy successfully calls the mint
function on that asset twice, once to mint the user share to the buyer and once to mint the contract pool share to the broker contract), its time to define the IBrokerWithdrawal
interface. It will require of course a withdraw(address, uint, bool)
function (bool determines asset or MATIC, true for asset), but given I haven't defined limits anywhere else yet, it'll also need limit(uint ethDenominator_ , uint deitDenominator_)
and limit()
functions, and a Withdrew(address, uint,uint)
event that emits the withdrawing account, the amount withdrawn and the amount remaining within the withdrawal limits.
interface IBrokerWithdrawal {
event Withdrew(bool indexed asset_, uint256 indexed amount_, uint256 indexed limitRemaining_);
function withdraw(address to_, uint amount_, bool asset_) external returns (bool);
function limit(uint256 ethLimitDenominator_, uint256 assetLimitDenominator_) external returns (bool);
function limit() external returns (uint256, uint256);
}
struct Schema {
uint256 _ethLimitDenominator;
uint256 _assetLimitDenominator;
uint256 _ethWithdrawn;
uint256 _assetsWithdrawn;
}
With some hacking and modifications to any relevant facets now taken care of, and the relevant tasks preemptively rolled for the new interface hooks, I can again call npx hardhat facet-upgrade
on any existing facets and npx hardhat facet-deploy
for the new Withdrawal facet, and sure enough after buying some of this token from the Broker contract, I can also withdraw 1/2 of the ETH sent for the purchase as determined by my limits. I can also transfer 100% of the pooled tokens using the same withdraw
function and a true set for the asset_
flag when the denominator is set to 1, as I'd expect to see. And that... is the contract side of the MVP that Phase 1 was after. One last piece left before calling it a win: the Broker=>dApp integration.
Broker dApp Integration
Fortunately, the functionality needed for the Broker contract is nearly identical to the functionality already present in the ERC20 integration, so this is largely just a matter of grinding out the work with a lot of copy/paste to create the Broker purchase form. It leverages useContractRead
to fetch the price from the broker contract, and then updates the MATIC/DEIT values based on the price before calling the buy
function on click. Aside from the tech debt/JS work mentioned below, this buy form easily chewed up the most time in the project since I'd never worked with wagmi
before and was way overcomplicating/overthinking the conversion change handling making UX bugs for myself... in the end "keep it simple stupid" won.
const [buyParams, setBuyParams] = useState({
ETH: String(1.0),
DEIT: String(100000),
amount: ethers.BigNumber.from(0),
options: { value: ethers.BigNumber.from(0) },
})
const { config, refetch, error } = usePrepareContractWrite({
address: props.BROKER_ADDRESS,
abi: props.BROKER,
functionName: 'buy',
args: [buyParams.amount],
overrides: buyParams.options,
})
The winning buyParams that ignore amount/options until the Buy button is clicked and then simply use parseEther
and react state/effect hooks along with wagmi's refetch
and write
functions to handle the rest. The asset values are set to the known price as the default, but are overwritten once the useContractRead
hook finishes and updated onChange
relative the price. This implementation is still really basic and lacks basic niceties... will pick at improving so I don't have to reroll a form like that again and have a grab & go option for future asset pair forms.
const handleBuy = event => {
event.preventDefault()
const _value = ethers.utils.parseEther(String(buyParams.ETH))
const _amount = ethers.utils.parseEther(String(buyParams.DEIT))
const _options = { value: _value }
setBuyParams({ ...buyParams, amount: _amount, options: _options })
refetch()
if (props.ethBalance >= _value) {
setBuying(true)
}
}
useEffect(() => {
if (buying && buyDEIT) {
buyDEIT()
setBuying(false)
}
}, [buying, buyDEIT, setBuying])
Testnet Deployment, DEIT.ca Web3 Edition launch & Next Steps
So there it is! Some polish to the contracts and JS Lib, and deploy scripts not captured here (but captured in the repo) later followed by a simple npx hardhat deploy --network mumbai
, and this simple Brokered ERC20 model is now running on Polygon Mumbai, and interactable in alpha at the new version of the DEIT site. It really is that simple to go from the base Diamond and Facet contracts, to a working (even if contrived) dApp. It's a workflow that allows you to change direction mid-project (see: renaming ERC20DelegatedMintingFacet
to ERC20BrokeredFacet
after already deploying contracts for a great example of the flexibility). And it's this workflow of digestible and iterative, but test/task oriented development that I'll aim to explore more with Diamond Driven Development and DEIT's future projects more generally.
Editorial/Devtooling Addendum
I wrote this as I developed this little toy project to capture my brain as I worked through a simple but relatable Diamond Driven Development workflow start to finish, which is a process I won't do quite the same again, likely just jot noting the process instead of switching back and forth writing as I go... that was a bit tedious/slow going due to context shifts :\
Along the way I learned... this starter repo has plenty of work left to do, and a chunk of the work on this project ended up being diving into starting to pull together the JSLib I'd been imagining for a while, so rather than capturing the thoughts of that work here, in addition to the 3 phase related articles covering token (check) => LP integration => pool & vault
identified at the start, I'll also be capturing a more JS/Hardhat oriented article while I work through the refactoring the starter repo needs for better testing/deployment management. I'd also intended to just run with wreckless abandon into a mainnet launch to show how simple Diamond dev makes it, but will leave it at a testnet version until that JS lib code cleanup work is out of the way, since it'll make management simpler and more robust if the token finds its way into any other service made in future projects (atm its intended as a toy made for a blog post, and that's all it is).
In its current state the contracts have been unit tested... but after I'd put a bunch of work into making the tests better support multiple diamonds... I then yanked them all out so I can start from scratch with the new JSLib I'm for now calling shinyblocks at the core (which powers the DEIT/Broker tasks and deployment scripts already, so you can see examples of its use there). They've also been audited with Slither and refactored accordingly, which I'll aim to also do with the diamond-hardhat starter repo, and given there will be a ton of static analysis flags raised in that repo I'm sure, I'll aim to capture working through each as I go to help give a crash course on smart contract auditing and debugging, hopefully arriving at a starter repo that's both more grab & go/flexible, and better quality code in the end.
Subscribe to my newsletter
Read articles from Brandon Thorn directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by