Yearn Bunny Vision
Designing a vault UI that can outlive its creator
In this post I will explain the design process behind a new Yearn Vaults UI I’ve built from scratch.
Constraints
Start with imposing some constraints on yourself and you’ll arrive at a better destination, in a more creative route, possibly learning a bunch of stuff along the way.
My distaste for dapps that are dependent on external services is well-known, so the first rule is:
Rule 1: No external APIs. All data must fetched from the Ethereum client alone, so the users can interact with the dapp at the complete privacy of their own node.
After seeing a dapp built in 2024 requiring four transactions to deposit and stake tokens, I went on researching1 ways to minimize the number of on-chain actions required by the user.
Rule 2: Showcase Permit2. This dapp will only allow permit deposits. It doesn’t try to be exhaustive in wallet support, but rather focuses on advancing the best thing we have at the moment.
Confidence in the dapp must start from a trusty contract. It doesn’t make sense to query an API or a proxy to match a token with a deposit contract.
Rule 3: Enforce provenance. When Yearn v2 was conceived, it came with a registry contract that also acted as a factory. Vaults deployed from registry are guaranteed to have the correct settings.
Rule 4: No maintenance. I wrote disperse2 in 2018 and have barely touched it since then. It’s important the primitives you make can outlive you, otherwise why bother automating things.
Design
Deposit contract
I started from a Permit2 deposit contract in Vyper. It’s rather strict as it doesn’t allow you to choose a vault to deposit to. Rather, it looks up the latestVault(token)
from the registry3. This guranatees you only deposit to the latest release of a vault created by a canonical factory.
3 v2.registry.ychad.eth
More importantly, it also means it would pick up new vault deployments as they are added to the registry.
@external
def deposit(token: address, amount: uint256, deadline: uint256, signature: Bytes[65]) -> uint256:
"""
@notice Deposit token into the latest official vault.
@dev Reuses deadline as nonce
"""
= registry_b.latestVault(token)
vault: address assert vault != empty(address) # dev: no vault for this token
# pull tokens using permit2
permit2.permitTransferFrom(
PermitTransferFrom({
permitted: TokenPermissions({token: token, amount: amount}),
nonce: deadline,
deadline: deadline
}),self, requestedAmount: amount}),
SignatureTransferDetails({to:
msg.sender,
signature,
)
assert ERC20(token).approve(vault, amount, default_return_value=True)
return ERC4626(vault).deposit(amount, msg.sender)
Wagmi frontend
After I started building the frontend, I noticed that the no API limitation causes some convoluted code. Some things like the list of supported vaults I only wanted to load once and it was crucial to keep other things like balances fresh. Naturally, I put the “once” things into useEffect
with no dependencies.
You can fit the rest in multicalls pretty easily with Wagmi4, as it will smartly delegate all the heavy lifting of keeping the data fresh to TanStack Query.
4 wagmi.sh
It has worked reasonably well, but I didn’t like seeing loading states when I clicked each token. I felt the app could be snappier.
EVM backend
Since I’ve gotten pretty far with the frontend by then, the required shape of the data to render it has become clearer.
The answer felt so obvious. I can delete all contract fetching code and get rid of all loading states by simply returning all the data I need from the contract itself.
I wrote a massive view function that consumes over 8 million gas that will serve as our sole backend. It iterates through two registries, reads all tokens and vaults they support, fetches token and vault metadata, and finally filters by tokens the user can deposit or vaults the user has a balance in.
@view
@external
def fetch_user_info(user: address) -> DynArray[TokenInfo, 500]:
"""
@notice Find all tokens and vaults where the user has a balance.
"""
500] = empty(DynArray[TokenInfo, 500])
vaults: DynArray[TokenInfo, for registry in [registry_a, registry_b]:
= registry.numTokens()
num_tokens: uint256 for token_id in range(500):
if token_id == num_tokens:
break
= registry.tokens(token_id)
token: ERC20 = token.balanceOf(user)
token_balance: uint256 = token.allowance(user, permit2.address)
permit2_allowance: uint256 = registry.numVaults(token)
num_vaults: uint256 for vault_id in range(20):
if vault_id == num_vaults:
break
= registry.vaults(token, vault_id)
vault: ERC20 = vault.balanceOf(user)
vault_balance: uint256 if token_balance > 1 or vault_balance > 1:
vaults.append(TokenInfo({
token: token.address,
vault: vault.address,
decimals: ERC20Detailed(token.address).decimals(),
token_balance: token_balance,
vault_balance: vault_balance,
permit2_allowance: permit2_allowance,
symbol: ERC20Detailed(token.address).symbol(),
vault_api: Vault(vault.address).apiVersion(),
vault_share_price: Vault(vault.address).pricePerShare(),== vault.address,
latest: registry_b.latestVault(token.address)
}))
return vaults
UX considerations
It is important for me the dapp is both simple and transparent. From there we can infer a few more rules.
Vault shares are an implementation detail. We only show the token balances and automatically convert vault shares to underlying assets.
Similarly, the interplay between the assets in the wallet and the vault invites us to group them together. We can even add a playful indicator that shows a percentage of a certain token that’s earning yield.
The next feature is designed to warm your heart as a dev. For all actions we show a corresponding pseudocode that you can copy over to a tool of your choosing and call the contract yourself.
For users, we are educating them that the EVM is kinda like Python, where you pay money to call function in programs and maybe receive some points.
Of course, we always reveal the contract addresses we are calling, so the user can double check the intent.
Tools for the job
Save your time by picking a good library of UI components. I built my first site with Tailwind alone. This one I started with shadcn/ui, but ultimately went with Radix. It has the level of abstraction that feels just right.
I made my previous dapp5 with Next.js and have soon realized I don’t need any of its features.
Noteable lines
As usual, I’m sharing the full source for you to learn. Here are some things you may find useful.
Buy multiple tokens in a single transaction. A great way to gas up a test account.
Call a contract without deploying it.
A dynamic notification that shows the transaction after it’s been broadcasted and resolves after it’s been confirmed.
An easy way to get quality token logos.
Format amounts to significant digits while keeping integer preision.
Final thoughts
This is my second React app and so far the journey has been quite smooth. The experience has certainly improved since the last time I’ve touched the frontend side.
Hope you learned something new from an inexperienced dev who has yet to learn what’s impossible.
I found it important not to share the work with anyone before I consider it finished. This allows you to have full creative control and fullfill your vision without clouding it.
You can try out the live version at here.
The code is available here.