Docs menu

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#

Solidity
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. YieldLinks never holds or approves a token.
  • Only the controller moves funds. Inherit ControlledSource and guard deposit and withdraw with onlyController.
  • 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.

Solidity
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. totalAssets is idle SAUCE plus xSAUCE held × SAUCE in the pool ÷ xSAUCE supply.
  • Two allowances. enter pulls SAUCE, so approve SAUCE. leave also pulls, so approve xSAUCE too. Without the second, leave reverts 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#

  1. Create contracts/sources/MySource.sol inheriting ControlledSource.
  2. Implement the three functions. Associate any HTS token it holds with HtsLib.associate.
  3. Add a mock venue under test/mocks/ and tests mirroring SauceStakingSource.t.sol: deposit, yield reaching the link, claim, refund, wrong token, non-controller.
  4. Select it in script/Deploy.s.sol and deploy. The frontend picks it up from deployedContracts.ts.
  5. If the token changes, update the SAUCE() read in CreateLinkForm.tsx and TOKEN_DECIMALS in utils/yieldlinks.
Shell
yarn foundry:test
yarn foundry:deploy --network hedera_testnet
Never hardcode decimals. SAUCE and USDC have 6. Read them from the token where you can.

05Next 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:

Solidity
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
}
Next · 8 minHedera notes