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 adds harvest, 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

TarnVault (tUSDG)0x8fa1DaC45aB83fCBF475B69092475A2665Db45Cf
TarnSwap0xc56da26Ee31dc8baCEc78Ea810c49Ff74c749207
Source vault (Steakhouse USDG, Morpho V2)0xBeEff033F34C046626B8D0A041844C5d1A5409dd
USDG0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168
Uniswap v3 factory0x1f7d7550B1b028f7571E69A784071F0205FD2EfA
Runtime hash, TarnVault0x044a77d1f71934c9c5dbd66acd7dc92c3639b145c467d894aed0d64d0b937781
Runtime hash, TarnSwap0x02713418b997bfbca464af5d5e3cf96bb515178d75769180f6626367daa46722

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 a USDG: principal goes up by a.
  • Withdraw or redeem s of your S shares: principal goes down by principal × s / S. What stays keeps its interest.
  • Transfer s of S shares: principal × s / S moves 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.

#PropertyAssertionsResult
1deposit10pass
2redeemAll5pass
3interestSplitsByShare5pass
4harvestUsdg8pass
5harvestStock10pass
6harvestMinOut4pass
7harvestNothing3pass
8harvestDeadline3pass
9transferMovesPrincipal5pass
10transferFromMovesPrincipal4pass
11spenderWithdraw4pass
12donationIgnored4pass
13inflation4pass
14buyMatchesQuote6pass
15sellMatchesQuote5pass
16swapMinOut3pass
17callbackRejectsCaller3pass
18cannotSpendOthersApproval4pass
19noPool3pass
20swapDeadline2pass
21partialFill5pass
22maxWithdrawBounded5pass
23sequence7pass
24selfTransfer3pass
25mintPath4pass
26harvestCannotTouchPrincipal5pass
27partialWithdrawKeepsInterest3pass
28harvestExactShares4pass

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.

BreakWhat it doesCaught by
harvest-no-burnharvest pays interest without burning the shares it is worthharvestUsdg
transfer-keeps-principaltransfers leave principal behind, so the recipient can harvest the sender's deposittransferMovesPrincipal
withdraw-keeps-principalexits do not reduce principalredeemAll
count-donationsraw USDG sent to the vault moves the share pricedonationIgnored
no-virtual-sharesa first depositor can round the next one downinflation
harvest-rounds-downharvest burns one share too few, paid for by everyone elseharvestExactShares
usdg-minout-ignoreda dollar harvest ignores the caller's floorharvestMinOut
principal-in-sharesprincipal recorded in the wrong unitdeposit
interest-is-everythingharvest can take the principalharvestUsdg
withdraw-principal-by-assetsa partial exit counts its interest as principal, so the interest on the rest vanishespartialWithdrawKeepsInterest
spender-no-allowanceanyone can withdraw anyone's positionspenderWithdraw
usdg-harvest-no-deadlinea dollar harvest ignores its deadlineharvestDeadline
callback-trusts-calleranyone can call the callback and spend an approvalcallbackRejectsCaller
accept-partial-filla thin pool half-fills a trade the user signed in fullpartialFill
swap-minout-ignoredslippage protection goneswapMinOut
swap-no-deadlinea stale signed trade executesswapDeadline
callback-pays-from-swapthe pool is paid from the contract's own balance instead of the buyerbuyMatchesQuote

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