Skip to main content

Contract architecture

Overview​

Farmenta is a small set of contracts with one job each. The market holds the money, the collateral and the debt ledger. Around it sit helper contracts that answer one question each: which pools are allowed, what a position is worth, what a token costs, and what the borrow rate is.

Think of a market as a bank branch. The branch keeps the vault and the books, and it asks an appraiser, a price board, a rule book and a rate table before it acts. It cannot rewrite any of them on its own.

A small example of how the pieces meet: Budi deposits a Uniswap v4 position into the Blue-chip market and asks for 1,500 USDG. The market asks CollateralPolicy whether the pool is listed and on what terms, asks PositionValuer what the position is worth (say $10,000), and the valuer asks PriceOracle for the ETH and USDG prices. With a max LTV of 65% the limit is $6,500, so the borrow passes and the market records a debt of 1500000000 (1,500 USDG with 6 decimals).

All contracts are written in Solidity 0.8.26 (pinned, not a range) and released under the MIT licence. The source is in the smart-contract repository.

Component diagram​

An arrow means "calls or reads". The two proxies share one implementation, so both markets run the same code with different storage.

Contracts​

ContractPurposeReference
FarmentaMarketERC-4626 vault for lenders, custody of position NFTs, debt ledger, liquidation. Deployed as two proxies: Blue-chip and Meme.FarmentaMarket
MarketLensRead-only risk and reserve views for one market. One lens per market.MarketLens
CollateralPolicyThe list of accepted pools and the terms of each one. Owner managed.CollateralPolicy
PriceOracleUSD prices: Chainlink for ETH and USDG, the TWAP recorder for meme tokens.PriceOracle
TwapRecorderPermissionless recorder of pool ticks, the source of the meme TWAP.TwapRecorder
PositionValuerValues a position at oracle prices and reports its uncollected fees. Stateless, no owner.PositionValuer
InterestRateModelOne contract carrying the borrow rate curves of both tiers.InterestRateModel
LiquidatorHelperPeriphery contract, outside the core: flash loan, liquidation and swap in one transaction.LiquidatorHelper

Every event and every custom error is listed on the events and errors pages. The numbers behind the terms are on the risk parameters page.

Farmenta contract addresses are on the addresses page.

External contracts​

External contractUsed byFor
Uniswap v4 PositionManagerFarmentaMarket, PositionValuer, LiquidatorHelperThe position NFT, minting, adding and removing liquidity, burning a position.
Uniswap v4 PoolManagerreached through PositionManager and StateViewHolds pool state and pays out tokens. Farmenta never calls it directly.
Uniswap v4 StateViewPositionValuer, TwapRecorder, PriceOracleReading pool price, tick, liquidity and fee growth.
Permit2FarmentaMarketPulling the borrower's tokens for mintAndDeposit and increaseLiquidity. The address is read from PositionManager.
Chainlink feedsPriceOracleETH/USD and USDG/USD prices.
MorphoLiquidatorHelperUSDG flash loans.
UniversalRouterLiquidatorHelperSwapping seized collateral to USDG.

See the Uniswap v4 documentation, the Chainlink data feeds documentation and the Morpho documentation for the third party contracts.

One proxy type, everything else is plain​

Only FarmentaMarket sits behind a proxy. It uses the UUPS pattern: the upgrade logic lives in the implementation. _authorizeUpgrade is restricted to the owner and accepts only the implementation that was scheduled, once its timelock has passed.

CollateralPolicy, PositionValuer, PriceOracle, InterestRateModel and TwapRecorder are plain, non-upgradeable contracts. The market stores the addresses of its dependencies as immutables of the implementation:

IPositionManager public immutable positionManager;
ICollateralPolicy public immutable policy;
IPositionValuer public immutable valuer;
IPriceOracle public immutable oracle;
IInterestRateModel public immutable interestRateModel;

The same pattern continues one level down: PriceOracle keeps the policy and the TwapRecorder as immutables, and PositionValuer keeps PositionManager, StateView and the oracle.

There are no setters for these. Pointing a market at a new policy, valuer, oracle or rate model means deploying a new implementation and upgrading the proxy to it. The reason is simple: every extra proxy is one more place where storage can collide, and it would add no capability the upgrade does not already give.

The tier of a market (Blue-chip or Meme) is stored in proxy storage, not in an immutable, because one implementation serves both proxies. For the same reason InterestRateModel is one contract that carries both curves and takes the tier as an argument.

Upgrades

Only the owner can authorize an upgrade of a market. The new implementation is scheduled with scheduleUpgrade and can be installed from TIMELOCK_DELAY (2 days) later, for TIMELOCK_GRACE (14 days), and only while the code at its address is the code that was scheduled. The owner is a TimelockController, so the scheduling call itself waits 2 days first, and an upgrade takes about 4 days in total. See owner powers.

Storage layout​

The market keeps its state under an ERC-7201 namespace, farmenta.storage.Market, declared once in the MarketLedger library:

struct Loan {
address owner;
ICollateralPolicy.Tier tier;
uint256 debtShares;
PoolId poolKeyId;
}

struct Layout {
ICollateralPolicy.Tier tier;
mapping(uint256 tokenId => Loan) loans;
mapping(PoolId poolId => uint256) poolDebtShares;
uint256 totalBorrowShares;
uint256 totalBorrows;
uint256 borrowIndex;
uint256 lastAccrual;
uint256 reserves;
uint16 reserveFactorBps;
uint16 reserveFloorBps;
uint256 totalReservesWithdrawn;
address pendingImplementation;
uint64 upgradeEta;
bytes32 pendingCodehash;
}

A namespaced layout starts at a slot derived from the namespace string instead of slot 0. Adding a variable in a later version cannot shift a slot that is already in use. The inherited OpenZeppelin upgradeable contracts namespace their own storage the same way.

Libraries hold the logic, the market holds the wrappers​

The market exposes thin functions and runs the work from libraries that are deployed on their own and linked to the implementation. Each call is a delegatecall, so the library code runs with the market's storage, the market's address and the original msg.sender.

LibraryHolds
MarketDebtInterest accrual, borrow, repay, the borrow price gates, and the health checks that run after a borrower action.
MarketMintCollateral admission, mintAndDeposit, increaseLiquidity.
MarketLiquiditycollectFees, decreaseLiquidity, and the recipient rule for payouts.
MarketLiquidationThe whole of liquidate: valuation, the seizure plan, ledger updates, payouts.
MarketUpgradeThe upgrade timelock: scheduling, cancelling, and the check that lets a scheduled implementation through.
MarketLedgerThe storage layout above. It declares where state lives, so the market and the five libraries all write the same slots.

Pure arithmetic sits in internal libraries that are compiled into their callers: DebtMath, LiquidationMath, PriceMath, PositionAmounts, TierPresets and HookPermissions.

What stays in the market is what has to be visible from outside: the pause check, the reentrancy guard, the ERC-4626 surface and the owner functions.

Events and errors come from the market address

Because the libraries run by delegatecall, every event they emit is logged by the market proxy, and every error they throw reverts the market call. To decode all of them, combine the ABI of FarmentaMarket with the ABIs of the five libraries. The liquidation errors, for example, are declared only in MarketLiquidation.

Units​

Two units appear everywhere, and they must never be mixed.

QuantityUnitExample
Debt, totalBorrows, poolDebt, reserves, debt caps, cashUSDG, 6 decimals1,500 USDG is 1500000000
Position value, fee value, minimum position value, all valuer outputUSD, scaled by 1e18$10,000 is 10000000000000000000000
PricesUSD for one whole token, scaled by 1e18$2,500 is 2500000000000000000000
Health factorratio, scaled by 1e181.25 is 1250000000000000000
LTV, LT, bonus, haircut, reserve factorbasis points, 10,000 is 100%65% is 6500
Borrow rateper second, scaled by 1e18see InterestRateModel
borrowIndexscaled by 1e18, starts at 1e18
Share token (fUSDG-BC, fUSDG-MEME)9 decimals (USDG decimals plus the offset of 3)

Debt is compared with collateral only after it has been converted to USD at the oracle price of USDG:

debtUsd = debt × price(USDG) / 10^decimals(USDG)

Token decimals are recorded when a token is configured in CollateralPolicy and are never read live from the token.

Debt caps are stored in USDG so that enforcing a cap never needs a price.

The outbound call rule​

Several market functions hand control to code the protocol does not own: a pool hook runs inside PositionManager, a native ETH transfer runs the recipient's code, and a payout goes to an address the caller picked.

The reentrancy guard covers the market's own functions and vault deposits. It deliberately does not cover the vault exits withdraw and redeem, because a lender should always be able to leave. That makes one rule binding on every function that calls out:

  • All bookkeeping (debt, debt shares, per pool debt, reserves, bad debt) finishes before the first external call that can run third party code.
  • The borrowed asset (USDG) leaves before any other asset. The one exception is a full seizure: the position is burned and PositionManager pays both tokens to the liquidator in pool order, after the ledger has been written.

The result is that code running in the middle of a market call always sees a ledger that is already settled. A lender who redeems from inside a callback is paid the same share price as one who redeems afterwards.

How each path applies it:

PathOrder
borrowLedger written, then USDG sent.
collectFees, decreaseLiquidityOnly the interest accrual writes, and it comes first. Tokens flow from PoolManager to the recipient, USDG leg first. The health checks that follow only read.
mintAndDeposit, increaseLiquidityThe caller's tokens go from Permit2 straight to PositionManager and never sit in the market. Change is swept back to the caller, USDG first.
liquidate, liquidity sliceThe slice arrives in the market, USDG is pulled from the liquidator, the ledger is written, then payouts leave, USDG first.
liquidate, full seizureUSDG is pulled and the ledger is written before the position is burned and paid out.

The vault and inflation attacks​

Each market inherits OpenZeppelin's ERC4626Upgradeable and follows the ERC-4626 standard. Lender funds are:

totalAssets = cash + totalBorrows − reserves

The vault uses a decimals offset of 3. Shares carry three more decimals than USDG, which adds virtual shares to the conversion. An attacker who tries to inflate the share price with a donation before the first deposit loses almost all of the donation to those virtual shares, so the classic first depositor attack does not pay.

Ownership​

ContractOwner
FarmentaMarket (each proxy)Ownable2StepUpgradeable. The owner can pause, withdraw reserves above the floor, rescue unaccounted assets, and upgrade through the timelock. The guardian can pause.
CollateralPolicyOwnable2Step. The owner configures tokens, the hook allowlist, listings, freezes and LT ramps. The guardian can freeze a pool, disable a token and revoke a hook.
PriceOracle, TwapRecorder, PositionValuer, InterestRateModel, MarketLens, LiquidatorHelperNo owner and no privileged function.

The owner of both markets and of the policy is a TimelockController with a delay of 2 days and no admin. Every owner call is scheduled in it by a proposer, waits the delay, and is run by an executor. Each of the three contracts also has a guardian, an account the owner names, which can stop new risk at once and cannot undo it. See owner powers.

Ownership transfers are two step: the current owner nominates, and the new owner must accept.