06 · 6 min read
Add a yield source
This is the part you change. YieldLinks is finished: it tracks links, checks signatures and keeps the books. Where the tokens sit while they wait is one contract with three functions.
01The interface#
interface IYieldSource {
/// Underlying-token value currently held for the controller, including accrued yield.
function totalAssets(address token) external view returns (uint256);
/// Pulls `amount` of `token` from `from` (who must have approved this source) and puts it to work.
function deposit(address token, address from, uint256 amount) external;
/// Sends `amount` of `token` to `to`, unwinding from the yield venue if needed.
function withdraw(address token, uint256 amount, address to) external;
}Three rules follow, and the tests check each one:
- The source is the only token holder.
YieldLinksnever holds or approves a token. - Only the controller moves funds. Inherit
ControlledSourceand guarddepositandwithdrawwithonlyController. - Never pay more than you report. Round so a withdrawal cannot exceed
totalAssets, or claims revert.
02The smallest source#
IdleSource.sol, complete. It earns nothing, and it is the starting point for every new venue.
contract IdleSource is ControlledSource {
using SafeERC20 for IERC20;
function totalAssets(address token) external view returns (uint256) {
return IERC20(token).balanceOf(address(this));
}
function deposit(address token, address from, uint256 amount) external onlyController {
HtsLib.associate(token);
IERC20(token).safeTransferFrom(from, address(this), amount);
}
function withdraw(address token, uint256 amount, address to) external onlyController {
_send(IERC20(token), to, amount);
}
}_send comes from ControlledSource: a HIP-904 airdrop on Hedera, a plain ERC-20 transfer anywhere else. HtsLib.associate accepts "already associated" and does nothing off Hedera, so the same contract runs in forge test.
03A real venue#
SauceStakingSource.sol is 77 lines. What it adds over the idle source is everything SaucerSwap taught us:
- Value at the pool ratio.
totalAssetsis idle SAUCE plus xSAUCE held × SAUCE in the pool ÷ xSAUCE supply. - Two allowances.
enterpulls SAUCE, so approve SAUCE.leavealso pulls, so approve xSAUCE too. Without the second,leavereverts with no useful message. - Round shares up when unstaking so the payout is covered, but never beyond what the source holds.
- Bound the shortfall. Two payouts from one link can come up a unit short. Accept at most
MAX_ROUNDING_SHORTFALL = 2, revert beyond it. - Reject other tokens with
UnsupportedToken.
04Ship it#
- Create
contracts/sources/MySource.solinheritingControlledSource. - Implement the three functions. Associate any HTS token it holds with
HtsLib.associate. - Add a mock venue under
test/mocks/and tests mirroringSauceStakingSource.t.sol: deposit, yield reaching the link, claim, refund, wrong token, non-controller. - Select it in
script/Deploy.s.soland deploy. The frontend picks it up fromdeployedContracts.ts. - If the token changes, update the
SAUCE()read inCreateLinkForm.tsxandTOKEN_DECIMALSinutils/yieldlinks.
yarn foundry:test
yarn foundry:deploy --network hedera_testnet05Next candidate: Bonzo#
Bonzo Finance, an Aave v2 fork, was the first choice and is not used today (see dead ends). If its testnet is fixed, a source is small:
contract BonzoSource is ControlledSource {
// deposit: transferFrom, approve the pool, pool.deposit(token, amount, address(this), 0)
// withdraw: pool.withdraw(token, amount, address(this)), then _send(token, to, amount)
// totalAssets: the aToken balance of this contract
}