Overview
Tarn is two contracts on Robinhood Chain (chain id 4663), plus this website.
- TarnVault (
tUSDG) is an ERC-4626 vault. It takes USDG and puts all of it into one source vault, fixed at deployment: Steakhouse USDG on Morpho. It also records each holder's principal, and addsharvest, which pays out only the value above it. - TarnSwap buys and sells through the Uniswap v3 pools of one factory, also fixed at deployment. Exact input, all or nothing, and it never holds a balance between calls.
Neither has an owner, an admin, a fee, a pause or an upgrade path. Together they're 9,496 bytes of runtime code.
Contracts
Source: TarnVault.sol, TarnSwap.sol, TarnTest.sol. Compiled with solc 0.8.26 (optimizer, 200 runs, Cancun) on OpenZeppelin Contracts 5.1.0.
Principal and interest
For each account the vault stores principalOf, the USDG it has put in. The rules:
- Deposit
aUSDG: principal goes up bya. - Withdraw or redeem
sof yourSshares: principal goes down byprincipal × s / S. What stays keeps its interest. - Transfer
sofSshares:principal × s / Smoves with them. Moving tokens between wallets never creates interest. - Interest is
max(0, previewRedeem(balance) − principal). - Harvest burns
previewWithdraw(interest)shares, rounded up so the rounding goes against the harvester. It leaves principal unchanged and pays the interest as USDG, or swaps it through TarnSwap into the stock you choose, with your minimum and deadline.
The vault counts only its shares of the source vault (source.previewRedeem(source.balanceOf(vault))), so USDG sent to it directly moves nothing. Six decimals of virtual shares stop a first depositor from rounding the next one down.
Why no maxWithdraw from the source? Steakhouse USDG is a Morpho Vault V2, which answers 0 to every max* query by design. An early build asked it and would have refused every deposit. The test suite caught that on its first run against the real vault. Tarn now uses OpenZeppelin's defaults, and if the source can't pay out, its own withdraw reverts.
Swaps
swapExactIn(tokenIn, tokenOut, fee, amountIn, minOut, to, deadline) asks the factory for the pool, swaps against it and pays it from the caller in the callback. The callback accepts only the pool the factory returns for that exact pair and fee. If the pool can't absorb the whole input before running out of liquidity, the trade reverts (PartialFill) instead of silently filling less. quoteExactIn runs the same swap and unwinds it, which is how the app quotes every fee tier and picks the best.
Robinhood's stock tokens are ERC-20s with a small share multiplier (uiMultiplier, within 0.5% of 1 today). Tarn shows token amounts exactly as your wallet does.
Tests
Each property is a Solidity function that deploys fresh copies of both contracts and runs against the live chain in one eth_call: real USDG, the real Morpho vault, the real Uniswap pools. Nothing is sent, and each property gets its own throwaway state. A property that asserts nothing counts as a failure. Interest is created the only way it can be inside one block: by handing the vault real Morpho shares bought with real USDG.
Last run: 28 properties, 131 assertions, all passing at block 70763771.
| # | Property | Assertions | Result |
|---|---|---|---|
| 1 | deposit | 10 | pass |
| 2 | redeemAll | 5 | pass |
| 3 | interestSplitsByShare | 5 | pass |
| 4 | harvestUsdg | 8 | pass |
| 5 | harvestStock | 10 | pass |
| 6 | harvestMinOut | 4 | pass |
| 7 | harvestNothing | 3 | pass |
| 8 | harvestDeadline | 3 | pass |
| 9 | transferMovesPrincipal | 5 | pass |
| 10 | transferFromMovesPrincipal | 4 | pass |
| 11 | spenderWithdraw | 4 | pass |
| 12 | donationIgnored | 4 | pass |
| 13 | inflation | 4 | pass |
| 14 | buyMatchesQuote | 6 | pass |
| 15 | sellMatchesQuote | 5 | pass |
| 16 | swapMinOut | 3 | pass |
| 17 | callbackRejectsCaller | 3 | pass |
| 18 | cannotSpendOthersApproval | 4 | pass |
| 19 | noPool | 3 | pass |
| 20 | swapDeadline | 2 | pass |
| 21 | partialFill | 5 | pass |
| 22 | maxWithdrawBounded | 5 | pass |
| 23 | sequence | 7 | pass |
| 24 | selfTransfer | 3 | pass |
| 25 | mintPath | 4 | pass |
| 26 | harvestCannotTouchPrincipal | 5 | pass |
| 27 | partialWithdrawKeepsInterest | 3 | pass |
| 28 | harvestExactShares | 4 | pass |
Sabotage sweep
A suite that passes has only been shown to pass. So the contracts were broken on purpose, one defect at a time, and the same suite was run against each broken build. Each break has to be caught by the property named for it, not just by something. Result: 17/17.
| Break | What it does | Caught by |
|---|---|---|
harvest-no-burn | harvest pays interest without burning the shares it is worth | harvestUsdg |
transfer-keeps-principal | transfers leave principal behind, so the recipient can harvest the sender's deposit | transferMovesPrincipal |
withdraw-keeps-principal | exits do not reduce principal | redeemAll |
count-donations | raw USDG sent to the vault moves the share price | donationIgnored |
no-virtual-shares | a first depositor can round the next one down | inflation |
harvest-rounds-down | harvest burns one share too few, paid for by everyone else | harvestExactShares |
usdg-minout-ignored | a dollar harvest ignores the caller's floor | harvestMinOut |
principal-in-shares | principal recorded in the wrong unit | deposit |
interest-is-everything | harvest can take the principal | harvestUsdg |
withdraw-principal-by-assets | a partial exit counts its interest as principal, so the interest on the rest vanishes | partialWithdrawKeepsInterest |
spender-no-allowance | anyone can withdraw anyone's position | spenderWithdraw |
usdg-harvest-no-deadline | a dollar harvest ignores its deadline | harvestDeadline |
callback-trusts-caller | anyone can call the callback and spend an approval | callbackRejectsCaller |
accept-partial-fill | a thin pool half-fills a trade the user signed in full | partialFill |
swap-minout-ignored | slippage protection gone | swapMinOut |
swap-no-deadline | a stale signed trade executes | swapDeadline |
callback-pays-from-swap | the pool is paid from the contract's own balance instead of the buyer | buyMatchesQuote |
One change is expected to go unseen: removing the from != to guard in the transfer hook. Moving principal from an account to itself nets to zero, so the guard only saves gas. The sweep runs it and confirms that no test fails.
Risks
- Not audited. The contracts are short and built on audited code, but nobody independent has reviewed them.
- Source vault risk. Your USDG is lent through Morpho by the Steakhouse curators. Bad debt there is a loss to tUSDG. The curator can change allocations within Morpho's rules. Tarn can't change the source.
- Liquidity. Withdrawals need Morpho to have idle USDG. If every dollar is lent out, withdrawals wait until borrowers repay or rates pull in new supply.
- Market risk. Stocks you buy or harvest into can fall. Tokenized stocks can be paused or restricted by their issuer.
- Rate. The yearly rate is Morpho's, measured from the source vault's own share price over the last 7 days (3.87%) and 30 days (3.77%). It isn't promised and it moves.
Deployment
Both contracts go through the standard CREATE2 deployer at 0x4e59b44847b379578588920ca78fbf26c0b4956c, with salt keccak256("tarn.v1"). The address is therefore a hash of the exact init code, constructor arguments included. Anyone can send the deployment by hand, and the app sends it for you (both contracts in one Multicall3 transaction) before the first deposit or trade. The result is identical, and no key controls it afterwards. The app compares the code at each address against the runtime hash above before using it.
to: 0x4e59b44847b379578588920ca78fbf26c0b4956c data: 0x263a1c7741ac6a1e882966f2c88feb7864dbc07253725e4ce10b25721c0890f0 ++ initcode