Концепции кредитования DeFi

Часть 1: Кредитование и заимствование

Мы рассмотрим, как протоколы DeFi облегчают заимствование и кредитование.

Этот пост является первым из серии из трех, в которых обсуждается, как работают протоколы кредитования DeFi — их ключевые компоненты, формулы и варианты использования. При этом мы подчеркнем, как, несмотря на различные и творческие номенклатуры, протоколы имеют тенденцию повторно использовать, повторять и делиться основными концепциями. Одной из таких общих реализаций, о которой мы подробно пишем в этом сообщении блога, является использование токенов ERC20 для представления доли пользователя в кредитных пулах. Мы начнем с выделения уникальных концептуальных элементов этих протоколов и предоставим технические концепции, чтобы различать, как они работают.

Кредитные пулы

В традиционных финансах (или TradFi) кредитование и заимствование осуществлялись при посредничестве сторонних финансовых учреждений. На эти финансовые учреждения были возложены две основные задачи: обеспечение выплаты процентов сторонами-заемщиками их кредитующим сторонам, а также оценка и недопущение участия сторон, считающихся ненадежными, в этой деятельности.

Напротив, в децентрализованных финансах (или DeFi) сторонним кредиторам и заемщикам нельзя доверять. Это отсутствие доверия вдохновило на создание нового дизайна для облегчения процессов кредитования в сети.

Кредитный пул — это смарт-контракт. Пользователи протокола DeFi могут вносить активы (обычно токены ERC20) с целью использования этого контракта для предоставления своих депонированных активов. Другие пользователи могут взаимодействовать с кредитными пулами, чтобы получать немедленные кредиты — заимствования под залог активов, депонированных в пуле.

Кредитные пулы имеют некоторые существенные преимущества по сравнению с кредитованием и заимствованием по сравнению с традиционными методами финансирования, например:

  • С DeFi кредиты не ограничены доступностью кредитования 1:1 на заемную сумму. Вместо этого средства от всех пользователей протокола вносятся в пул, создавая таким образом достаточно значительный запас токенов для немедленного размещения кредитов.
  • DeFi отказывается от графиков погашения. Кредиты оформляются под ранее внесенное обеспечение, и пользователи могут выбрать погашение в любое время.

На этом этапе вы можете задаться вопросом: «Зачем мне заимствовать активы по протоколу кредитования, если я должен предоставить равноценные (или даже переоцененные) активы в качестве залога? Разве я не должен вместо этого продать залог и купить заемные активы?»

Что ж, то, что протоколы кредитования DeFi, похоже, допускают только полностью (или сверх) обеспеченные кредиты, открывает дверь для интересной методологии «торговли»: кредитного плеча.

Представьте, что вы настроены оптимистично по отношению к WBTC — на 100% уверены, что его стоимость взлетит до небес! Вы можете внести немного WBTC (скажем, на сумму 1000 долларов) на свой любимый протокол кредитования, использовать его для заимствования стейблкоина (т.е. USDC), с помощью которого вы затем можете купить еще больше WBTC (для нашего сценария, скажем, половину вашего первоначального депозита — 500 долларов) на какой-нибудь бирже. В этом сценарии вы будете подвержены воздействию WBTC на 1500 долларов по сравнению с вашими первоначальными 1000 долларов.

Но подождите, это еще не все! Что, если вы внесете залог WBTC на сумму 500 долларов, чтобы занять под него еще больше USDC? Этот процесс известен как чрезмерное использование заемных средств, и вы можете делать это несколько раз до тех пор, пока политика протокола не перестанет это делать, как только вы превысите свои возможности заимствования.

В аналогичном сценарии предположим, что вы настроены по-медвежьи на WBTC (в конце концов ❄️, это криптозима). Вы можете сделать противоположное нашему предыдущему сценарию и внести USDC в качестве залога, чтобы занять WBTC, от которого вы сразу же избавитесь для получения дополнительной части этого стейблкоина. Если ваш прогноз сбудется, и цена WBTC упадет, вы сможете купить ту же сумму, которую вы заимствовали, дешевле на бирже, погасить кредит и сохранить избыток USDC, открыв (и закрыв) короткую позицию по WBTC!

«Поделиться токенами»

Как и в случае с TradFi, пользователи, которые размещают свои активы в кредитных пулах, заинтересованы в том, чтобы хранить свои средства в пуле в долгосрочной перспективе с процентами по своим депозитам. Проценты начисляются с течением времени, рассчитываются как процент от депозита пользователя в кредитном пуле по протоколу и запрашиваются соответствующим пользователем, вносящим депозит. Чем дольше пользователь хранит свои активы в кредитном пуле, тем больше процентов они начисляют.

Как протокол учитывает долю каждого пользователя в пуле? Помните, что когда один пользователь вносит активы в пул, его «доля» размывает долю всех пользователей, и протокол отражает это соответствующим образом. Однако протокол не отслеживает и не обновляет долю каждого пользователя в пуле при каждом случае вывода средств или депозита напрямую. Делать это в цепочке было бы чрезвычайно расточительно и непомерно дорого для вкладчиков, которые будут оплачивать счета за каждое выполнение обновления.

Вместо этого протокол предназначен для обработки только изменения доли вкладчика без необходимости активного обновления акций других пользователей.

Вы можете подумать, что этот протокол позволяет вам съесть торт и съесть его. Но не совсем:

Протоколы обрабатывают выдачу процентов путем чеканки и сжигания токенов ERC20, которые мы будем называть «токенами акций», которые представляют собой долю кредитора (или долю депонированных активов) в его кредитном пуле. Этот дизайн «токена акций» автоматически корректирует разводнение акций других «акционеров», чтобы отразить чеканку и сжигание «акций», пропорциональное депонированию или выводу их базовых активов соответственно.

Ниже мы приведем реальные примеры того, как различные протоколы используют «токены обмена», и обсудим их сходство.

aToken: «Share Token» AAVE

aTokens — это токены AAVE, приносящие доход, которые чеканятся и сжигаются кредитным пулом при внесении и выводе активов в протокол или из него соответственно.

aToken— это токены , подобные ERC20, интегрированные в протокол AAVE, так что для каждого из рынков, на которые пользователь может выйти (внести залог), есть aToken.

Если мы посмотрим на контракт кредитного пула AAVE, мы увидим основные операции, которые происходят, когда пользователь вносит активы в пул:

pragma solidity ^0.8.13;

function deposit(
address asset,
uint256 amount,
address onBehalfOf,
uint16 referralCode
) external override whenNotPaused {
DataTypes.ReserveData storage reserve = _reserves[asset];

ValidationLogic.validateDeposit(reserve, amount);

address aToken = reserve.aTokenAddress;

reserve.updateState();
reserve.updateInterestRates(asset, aToken, amount, 0);

IERC20(asset).safeTransferFrom(msg.sender, aToken, amount);

bool isFirstDeposit = IAToken(aToken).mint(onBehalfOf, amount, reserve.liquidityIndex);

if (isFirstDeposit) {
_usersConfig[onBehalfOf].setUsingAsCollateral(reserve.id, true);
emit ReserveUsedAsCollateralEnabled(asset, onBehalfOf);
}

emit Deposit(asset, msg.sender, onBehalfOf, amount, referralCode);

Мы видим, что aToken, соответствующий рынку, который пользователь внес на депозит, будет вызван с функцией mint.

pragma solidity ^0.8.13;

function mint(
address user,
uint256 amount,
uint256 index
) external override onlyLendingPool returns (bool) {
uint256 previousBalance = super.balanceOf(user);

uint256 amountScaled = amount.rayDiv(index);
require(amountScaled != 0, Errors.CT_INVALID_MINT_AMOUNT);
_mint(user, amountScaled);

emit Transfer(address(0), user, amount);
emit Mint(user, amount, index);

return previousBalance == 0;
}

Как мы видим, фактическая сумма, которую нужно отчеканить, составляет:

В нашем примере пользователь присоединился к рынку, резерв (или пул) которого уже начислил проценты по его ранее депонированным активам. Приведенное выше уравнение помогает нам понять это, так как показывает, как учесть начисление процентов всем пользователям с помощью глобального индекса, который обновляется при различных действиях (депозиты, снятие средств и т. д.).

Когда пользователь выводит свой базовый актив, liquidityIndex используется в качестве множителя для расчета количества токенов, причитающихся в транзакции.

Ниже приведен соответствующий фрагмент кода из договора о кредитном пуле:

pragma solidity ^0.8.13;

function withdraw(
address asset,
uint256 amount,
address to
) external override whenNotPaused returns (uint256) {
DataTypes.ReserveData storage reserve = _reserves[asset];

address aToken = reserve.aTokenAddress;

uint256 userBalance = IAToken(aToken).balanceOf(msg.sender);

uint256 amountToWithdraw = amount;

if (amount == type(uint256).max) {
amountToWithdraw = userBalance;
}

ValidationLogic.validateWithdraw(
asset,
amountToWithdraw,
userBalance,
_reserves,
_usersConfig[msg.sender],
_reservesList,
_reservesCount,
_addressesProvider.getPriceOracle()
);

reserve.updateState();

reserve.updateInterestRates(asset, aToken, 0, amountToWithdraw);

if (amountToWithdraw == userBalance) {
_usersConfig[msg.sender].setUsingAsCollateral(reserve.id, false);
emit ReserveUsedAsCollateralDisabled(asset, msg.sender);
}

IAToken(aToken).burn(msg.sender, to, amountToWithdraw, reserve.liquidityIndex);

emit Withdraw(asset, msg.sender, to, amountToWithdraw);

return amountToWithdraw;
}

Здесь функция balanceOf контракта aToken странная! В конце концов, мы только что установили, что количество отчеканенных aTokens отклоняется от количества депонированных базовых активов. Как получается, что вызов IAToken(aToken).balanceOf(address(user)) дает сумму базовых активов, подлежащих выводу (как показано в нижней части функции)? Колодец:

  • Когда пользователь выводит свои активы, его aTokens сжигаются. Эти сожженные aTokens сохраняют общее количество aTokens, которыми владеют другие пользователи, пропорционально их доле в пуле после того, как активы пользователей были выведены.
  • Процентные ставки рынка, с которого пользователь выводит свои средства, обновляются при каждом снятии средств.

Как мы установили ранее, токены — это токены, подобные ERC20. Подчеркнем, что они «похожи» на токены ERC-20 из-за уникальных свойств их функции balanceOf. В обычных токенах ERC20 функция balanceOf возвращает количество токенов, которыми владеет адрес.

aTokenaToken s представляют собой долю пула, а не прямое значение, функция aToken возвращает сумму базовых balanceOf токенов, причитающихся пользователю протоколом за его депозиты.

pragma solidity ^0.8.13;

function balanceOf(address user)
public
view
override(IncentivizedERC20, IERC20)
returns (uint256)
{
return super.balanceOf(user).rayMul(_pool.getReserveNormalizedIncome(_underlyingAsset));
}

balanceOf эта функция balanceOf переопределяет balanceOf из унаследованных контрактов aToken. В результате логика balanceOf в этом примере выполняется вместо обычного (унаследованного) поиска сопоставления количества маркеров пользователя.

Указанное выше количество токенов затем умножается на результат getReserveNormalizedIncome, который, в свою очередь, выполняет следующую логику:

pragma solidity ^0.8.13;

function getNormalizedIncome(DataTypes.ReserveData storage reserve)
internal
view
returns (uint256)
{
uint40 timestamp = reserve.lastUpdateTimestamp;

if (timestamp == uint40(block.timestamp)) {
return reserve.liquidityIndex;
}

uint256 cumulated =
MathUtils.calculateLinearInterest(reserve.currentLiquidityRate, timestamp).rayMul(
reserve.liquidityIndex
);

return cumulated;
}

Мы можем определить ветвление здесь:

  • Если в этом блоке уже произошло обновление данных резерва: верните значение liquidityIndex для этого рынка в том виде, в котором оно уже обновлено.
  • В противном случае: нам нужно посмотреть, что происходит в calculateLinearInterest, чтобы узнать следующий поток, который будет выполнен.

pragma solidity ^0.8.13;

function calculateLinearInterest(uint256 rate, uint40 lastUpdateTimestamp)
internal
view
returns (uint256)
{
uint256 timeDifference = block.timestamp.sub(uint256(lastUpdateTimestamp));

return (rate.mul(timeDifference) / SECONDS_PER_YEAR).add(WadRayMath.ray());
}

В эту функцию передаются currentLiquidityRate и lastUpdateTimestamp из объекта ReserveData рынка, и результат будет следующим:

Давайте разберем компоненты этого уравнения, чтобы лучше понять суть значения linearInterest:

  • currentLiquidityRate: Думайте об этом как о APY (годовая процентная доходность) рынка, на котором мы работаем.
  • block_{timestamp} — lastUpdatedTimestamp: время, прошедшее с момента последнего обновления.

Примечание: так как мы взяли 2-ю ветку в getNormalizedIncome, то гарантируется, что это значение на данный момент положительное!

Таким образом, мы можем думать об этом механизме начисления процентов как о простом механизме начисления процентов, который складывается с каждым блоком. Теперь, когда мы установили сумму процентов, начисляемых нашему пользователю, нам просто нужно умножить это значение на индекс ликвидности, чтобы получить нормализованный доход пользователя, умноженный в функции balanceOf:

Теперь мы понимаем логику, лежащую в основе aTokens, но нам все еще предстоит разгадать тайну того, как работает liquidityIndex.

В приведенном ниже примере индекс ликвидности можно определить как проценты, накопленные резервом за некоторый период времени:

pragma solidity ^0.8.13;

function _updateIndexes(
DataTypes.ReserveData storage reserve,
uint256 scaledVariableDebt,
uint256 liquidityIndex,
uint256 variableBorrowIndex,
uint40 timestamp
) internal returns (uint256, uint256) {
uint256 currentLiquidityRate = reserve.currentLiquidityRate;

uint256 newLiquidityIndex = liquidityIndex;
uint256 newVariableBorrowIndex = variableBorrowIndex;

if (currentLiquidityRate > 0) {
uint256 cumulatedLiquidityInterest =
MathUtils.calculateLinearInterest(currentLiquidityRate, timestamp);
newLiquidityIndex = cumulatedLiquidityInterest.rayMul(liquidityIndex);
require(newLiquidityIndex <= type(uint128).max, Errors.RL_LIQUIDITY_INDEX_OVERFLOW);

reserve.liquidityIndex = uint128(newLiquidityIndex);

if (scaledVariableDebt != 0) {
  uint256 cumulatedVariableBorrowInterest =
    MathUtils.calculateCompoundedInterest(reserve.currentVariableBorrowRate, timestamp);
  newVariableBorrowIndex = cumulatedVariableBorrowInterest.rayMul(variableBorrowIndex);
  require(
    newVariableBorrowIndex <= type(uint128).max,
    Errors.RL_VARIABLE_BORROW_INDEX_OVERFLOW
  );
  reserve.variableBorrowIndex = uint128(newVariableBorrowIndex);
}

}

reserve.lastUpdateTimestamp = uint40(block.timestamp);
return (newLiquidityIndex, newVariableBorrowIndex);
}
}

Вспомним прежнюю переменную liquidityRate — теперь мы обсудим ее использование при расчете liquidityRate.liquidityIndex Накопление процентов будет происходить только в том случае, если liquidityRate больше 0 — другими словами, только если на этом рынке есть APY. Имеет смысл.

Давайте кратко рассмотрим, что на самом деле делает calculateLinearInterest:

pragma solidity ^0.8.13;

function calculateLinearInterest(uint256 rate, uint40 lastUpdateTimestamp)
internal
view
returns (uint256)
{
uint256 timeDifference = block.timestamp.sub(uint256(lastUpdateTimestamp));

return (rate.mul(timeDifference) / SECONDS_PER_YEAR).add(WadRayMath.ray());
}

Приведенная выше логика преобразуется в следующее уравнение:

Как мы видим в контракте DefaultReserveInterestRateStrategy.sol, liquidityRate определяется следующим образом:

pragma solidity ^0.8.13;

function calculateInterestRates(
address _reserve,
uint256 _availableLiquidity,
uint256 _totalBorrowsStable,
uint256 _totalBorrowsVariable,
uint256 _averageStableBorrowRate
)
external
view
returns (
uint256 currentLiquidityRate,
uint256 currentStableBorrowRate,
uint256 currentVariableBorrowRate
)
{

uint256 utilizationRate = (totalBorrows == 0 && _availableLiquidity == 0)
? 0
: totalBorrows.rayDiv(_availableLiquidity.add(totalBorrows));

// …

currentLiquidityRate = getOverallBorrowRateInternal(
_totalBorrowsStable,
_totalBorrowsVariable,
currentVariableBorrowRate,
_averageStableBorrowRate
)
.rayMul(utilizationRate);

}

Так, его можно записать как:

overallBorrowRate, в свою очередь, определяется здесь:

pragma solidity ^0.8.13;

function getOverallBorrowRateInternal(
uint256 _totalBorrowsStable,
uint256 _totalBorrowsVariable,
uint256 _currentVariableBorrowRate,
uint256 _currentAverageStableBorrowRate
) internal pure returns (uint256) {
uint256 totalBorrows = _totalBorrowsStable.add(_totalBorrowsVariable);

if (totalBorrows == 0) return 0;

uint256 weightedVariableRate = _totalBorrowsVariable.wadToRay().rayMul(
    _currentVariableBorrowRate
);

uint256 weightedStableRate = _totalBorrowsStable.wadToRay().rayMul(
    _currentAverageStableBorrowRate
);

uint256 overallBorrowRate = weightedVariableRate.add(weightedStableRate).rayDiv(
    totalBorrows.wadToRay()
);

return overallBorrowRate;

}
}

И мы можем записать это как:

И utilizationRate можно определить как:

При определении utilizationRate легче думать о соотношении между объемом ликвидности в резерве (в настоящее время заемной) и общей ликвидностью на рынке, которое можно упростить следующим образом:

Теперь мы можем использовать эти 2 определения, чтобы записать уравнение для уровня ликвидности:

totalBorrows находится и в числителе, и в знаменателе, мы можем написать:

На данный момент достаточно разработать уравнение для liquidityRate. К этому определению мы еще вернемся.

cToken: «Токен акций» Compound

Давайте перейдем к нашему следующему примеру протокола кредитования, Compound.

Compound использует «токен акций» под названием cToken для обработки заимствований и кредитования. Этот токен ведет бухгалтерский учет всех активов, доступных пользователям для заимствования и кредитования в протоколе Compound.

Подобно тому, что мы обсуждали с AAVE V2, «токены акций» Compound чеканятся и используются для погашения базовых активов.

Compound использует обменный курс, аналогичный индексу ликвидности AAVE V2, чтобы решить, сколько cTokenдолжно быть отчеканено. Этот обменный курс является функцией:

pragma solidity ^0.8.13;

function exchangeRateStoredInternal() internal view returns (MathError, uint) {
uint _totalSupply = totalSupply;
if (_totalSupply == 0) {
return (MathError.NO_ERROR, initialExchangeRateMantissa);
} else {
uint totalCash = getCashPrior();
uint cashPlusBorrowsMinusReserves;
Exp memory exchangeRate;
MathError mathErr;

    (mathErr, cashPlusBorrowsMinusReserves) = addThenSubUInt(totalCash, totalBorrows, totalReserves);
    if (mathErr != MathError.NO_ERROR) {
        return (mathErr, 0);
    }

    (mathErr, exchangeRate) = getExp(cashPlusBorrowsMinusReserves, _totalSupply);
    if (mathErr != MathError.NO_ERROR) {
        return (mathErr, 0);
    }

    return (MathError.NO_ERROR, exchangeRate.mantissa);
}

}

Давайте объясним ключевые термины здесь:

  • totalCash: количество базовых токенов ERC20, принадлежащих учетной записи cToken.
  • totalBorrows: Количество базовых токенов ERC20, ссужаемых заемщикам вне рынка.
  • totalReserves: количество базовых токенов ERC20, зарезервированных для вывода или передачи через управление.
  • totalSupply: функция ERC20, которая возвращает общее количество cTokens.

В этом контексте мы можем написать уравнение обменного курса Compound:

Когда пользователь вносит токены ERC20, обменный курс определяет, сколько cTokenбудет отчеканено взамен:

pragma solidity ^0.8.13;

function mintFresh(address minter, uint mintAmount) internal returns (uint, uint) {
uint allowed = comptroller.mintAllowed(address(this), minter, mintAmount);
if (allowed != 0) {
return (failOpaque(Error.COMPTROLLER_REJECTION, FailureInfo.MINT_COMPTROLLER_REJECTION, allowed), 0);
}

if (accrualBlockNumber != getBlockNumber()) {
    return (fail(Error.MARKET_NOT_FRESH, FailureInfo.MINT_FRESHNESS_CHECK), 0);
}

MintLocalVars memory vars;

(vars.mathErr, vars.exchangeRateMantissa) = exchangeRateStoredInternal();
if (vars.mathErr != MathError.NO_ERROR) {
    return (failOpaque(Error.MATH_ERROR, FailureInfo.MINT_EXCHANGE_RATE_READ_FAILED, uint(vars.mathErr)), 0);
}

vars.actualMintAmount = doTransferIn(minter, mintAmount);

(vars.mathErr, vars.mintTokens) = divScalarByExpTruncate(vars.actualMintAmount, Exp({mantissa: vars.exchangeRateMantissa}));
require(vars.mathErr == MathError.NO_ERROR, "MINT_EXCHANGE_CALCULATION_FAILED");


(vars.mathErr, vars.totalSupplyNew) = addUInt(totalSupply, vars.mintTokens);
require(vars.mathErr == MathError.NO_ERROR, "MINT_NEW_TOTAL_SUPPLY_CALCULATION_FAILED");

(vars.mathErr, vars.accountTokensNew) = addUInt(accountTokens[minter], vars.mintTokens);
require(vars.mathErr == MathError.NO_ERROR, "MINT_NEW_ACCOUNT_BALANCE_CALCULATION_FAILED");

totalSupply = vars.totalSupplyNew;
accountTokens[minter] = vars.accountTokensNew;

emit Mint(minter, vars.actualMintAmount, vars.mintTokens);
emit Transfer(address(this), minter, vars.mintTokens);

return (uint(Error.NO_ERROR), vars.actualMintAmount);

}

А количество cTokenопределяется следующим уравнением:

eToken: «Share Token» Эйлера

Чтобы еще больше подчеркнуть, насколько похожи эти протоколы, мы разберем другой протокол кредитования, Эйлера, и рассмотрим, как он обрабатывает кредитование и заимствование.

В приведенном ниже примере функция депозита позволяет пользователям вносить токены ERC20 в обмен на… eTokens.

pragma solidity ^0.8.13;

function deposit(uint subAccountId, uint amount) external nonReentrant {
(address underlying, AssetStorage storage assetStorage, address proxyAddr, address msgSender) = CALLER();
address account = getSubAccount(msgSender, subAccountId);

updateAverageLiquidity(account);
emit RequestDeposit(account, amount);

AssetCache memory assetCache = loadAssetCache(underlying, assetStorage);

if (amount == type(uint).max) {
    amount = callBalanceOf(assetCache, msgSender);
}

amount = decodeExternalAmount(assetCache, amount);

uint amountTransferred = pullTokens(assetCache, msgSender, amount);
uint amountInternal;

unchecked {
    assetCache.poolSize -= amountTransferred;
    amountInternal = underlyingAmountToBalance(assetCache, amountTransferred);
    assetCache.poolSize += amountTransferred;
}

increaseBalance(assetStorage, assetCache, proxyAddr, account, amountInternal);

if (assetStorage.users[account].owed != 0) checkLiquidity(account);

logAssetStatus(assetCache);

}

Как мы видим, internalAmount — это количество eToken, отчеканенных для этого перевода.

pragma solidity ^0.8.13;

function computeExchangeRate(AssetCache memory assetCache) private pure returns (uint) {
uint totalAssets = assetCache.poolSize + (assetCache.totalBorrows / INTERNAL_DEBT_PRECISION);
if (totalAssets == 0 || assetCache.totalBalances == 0) return 1e18;
return totalAssets * 1e18 / assetCache.totalBalances;
}

function underlyingAmountToBalance(AssetCache memory assetCache, uint amount) internal pure returns (uint) {
uint exchangeRate = computeExchangeRate(assetCache);
return amount * 1e18 / exchangeRate;
}

Еще одно прямое совпадение с Compound: название и функция exchangeRate.

Давайте объясним ключевые параметры, которые используются для расчета обменного курса:

  • poolSize: результат вызова balanceOf(address) с адресом контракта пула в контракте ERC20 базового актива.
  • totalBorrows: Общая сумма ERC20, заимствованная и в настоящее время не представленная в пуле.
  • totalBalances: Общие балансы всех держателей eToken.

Итак, уравнение будет таким:

Обобщение

Напомним, что мы рассмотрели 3 протокола кредитования:

  • AAVE V2
  • Соединение
  • Эйлера

Мы рассмотрели чеканку «токенов акций» и то, как они обмениваются на депонированные активы через кредитные пулы.

Три уравнения, которые мы привели, чтобы показать, как работают эти процессы, можно обобщить в одно простое уравнение:

Имейте в виду, что обменные курсы могут быть определены любым способом, определенным протоколом. Эти произвольные ставки могут увеличить количество отчеканенных токенов, если $<$1, и уменьшить сумму, если $>$1.

Как в AAVE V2, так и в Compound мы увидели некоторое сходство в том, как определяется переменная $someRate$. В Compound someRate имеет следующий вид:

А для AAVE V2 someRate определяется как:

При этом уровень ликвидности определяется как:

Хотя мы не можем обобщить ставку для каждого протокола, как для AAVE2, так и для Compound, мы знаем, что ставка является функцией общей ликвидности на рынке. Возвращаясь к нашим уравнениям, учитывая, что totalLiquidity — это общее количество базовых токенов ERC20 на рынке, числитель в выражении exchangeRate и знаменатель в liquidityRate функционально одинаковы.

Просто подытожим: эти протоколы практически одинаковы. Хотя иногда они могут использовать разную номенклатуру, при разбиении на уравнения каждый компонент служит одной и той же цели при их реализации. Мы предлагаем читателям выбрать случайный протокол кредитования и проверить, применимо ли обобщение, которое мы здесь обсуждали, и там.

часть 2: Ликвидация

Мы рассмотрим, как работают ликвидации DeFi и почему они важны.

Этот пост является вторым из серии из трех, в которых обсуждается, как работают протоколы кредитования DeFi — их ключевые компоненты, формулы и варианты использования. В нашем предыдущем посте мы рассмотрели основные операции Defi, кредитование и заимствование, а также то, как различные протоколы решили реализовать эти операции (помните «Share Tokens»?) В этом посте мы сосредоточимся на том, что, по нашему мнению, является одной из самых захватывающих концепций кредитования DeFi: ликвидация.

Чрезмерное обеспечение и безнадежные долги

Возможно, вы помните из нашего предыдущего сообщения в блоге, что пользователи протокола могут заимствовать активы только под переменный процент от залога, который они предоставили протоколу. Это имеет смысл, поскольку протокол должен гарантировать, что он может вернуть свои активы (или любой другой актив, оцененный одинаково) у вас, если вы не сможете погасить свой долг. Этот процесс залога активов начался в TradFi, например, сегодня можно было выставить свой дом или Lamborghini в качестве обеспечения погашения кредита.

Обеспечение опирается на предпосылку, что цена залога сохраняет свою ценность — хотя цена дома или Lamborghini не может быть гарантирована, их соответствующая стоимость сравнительно менее волатильна, чем у ERC20 или NFT.

В большинстве протоколов кредитования DeFi ваши залоговые кредитные активы должны быть более ценными, чем кредит, который вы берете, также известный как чрезмерное обеспечение.

В интересах кредитных протоколов разрешать только кредиты с чрезмерным обеспечением, если они хотят оставаться финансово стабильными. Представьте, что вы предоставляете какой-то актив в качестве залога, и стоимость этого актива внезапно падает ниже стоимости активов, которые вы одолжили по протоколу. Теперь, когда ваш залог стоит меньше, чем ваш долг перед протоколом, у вас нет стимула погашать кредит. В конце концов, залог, который вы спасете в процессе погашения кредита, теперь будет стоить меньше, чем сумма, необходимая вам для фактического погашения кредита. Этот кредит сейчас неплатежеспособен.

Каждый неплатежеспособный кредит плох для их протокола. Долг, возникший из-за неплатежеспособных кредитов, создает незащищенность в протоколах, в конце концов, сумма долга — это сумма активов, которые кредиторы не могут вернуть из протокола. Чтобы подчеркнуть, насколько плох этот долг: если бы в протоколе был эквивалент «набега из банка» TradFi, последние пользователи, которые вывели свои активы из протокола, не смогли бы этого сделать.

Естественно, протоколы с большими суммами безнадежных долгов менее привлекательны для пользователей.

Ликвидация и порог ликвидации

Мы установили, что, когда стоимость залога кредита падает ниже стоимости долга заемщика с процентами, также известного как недостаточное обеспечение, долг заемщика представляет угрозу для здоровья его кредитного протокола. Чтобы предотвратить накопление позиций с недостаточным обеспечением, протоколы позволяют третьим сторонам, которые не обязательно являются пользователями протокола, погашать долг заемщиков с недостаточным обеспечением (или близким к недостаточному обеспечению). Погасив недостаточно обеспеченные долги, эти третьи стороны, также известные как ликвидаторы, имеют право требовать залоговое обеспечение своего застрахованного должника со скидкой. Этот процесс известен как ликвидация.

Вам может быть интересно: почему протоколы полагаются на третьи стороны для ликвидации нездоровых позиций? В конце концов, протоколы могут впихнуть в свой код механизм автоматической ликвидации.

Отправка ликвидационных сделок стоит немало газа. Если бы протокол автоматизировал отправку этих дорогостоящих транзакций, результирующие затраты на газ увеличили бы их эксплуатационные расходы до объема, который подорвал бы их прибыль.

Кроме того, автоматизированную ликвидационную систему было бы чрезвычайно сложно спроектировать. Протокол должен будет учитывать не только вопрос о том, следует ли автоматически ликвидировать позицию, но и когда это делать, со скоростью, отражающей волатильность рынка. Гораздо проще передать этот процесс на аутсорсинг, стимулируя специализированные третьи стороны к ликвидации этих позиций.

Ликвидация по своей сути не является прибыльной — чтобы этот процесс был прибыльным, залог должника должен стоить больше, чем его долг. Ликвидаторы не ликвидируют позицию, не будучи уверенными в том, что этот процесс принесет им пользу.

Но когда позиция становится ликвидной? Это условие определяется протоколом, функцией их ликвидационного порога, присваиваемого каждому активу.

При порогах ликвидации время имеет существенное значение. Как мы знаем, если стоимость долга позиции превышает ее залоговую стоимость, ликвидация этих позиций не выгодна ликвидаторам, а протокол обременен безнадежной задолженностью. Таким образом, безопасные пороги ликвидации дают ликвидаторам достаточно времени для ликвидации чрезмерно обеспеченных позиций до того, как они достигнут состояния неплатежеспособности.

Теперь, когда мы понимаем стимулы для каждой из вовлеченных сторон, чтобы сохранить здоровую позицию, мы покажем примеры того, как протоколы на самом деле реализуют эти механизмы:

Состав: Ликвидность счета

Compound относится к их порогу ликвидации для позиции с параметром AccountLiquidity, вычисляемым основным контрактом Compound, контролером.

У контролера есть функция getAccountLiquidity() которая возвращает информацию о ликвидности счета. Внутренне эта функция вызывает getHypotheticalAccountLiquidityInternal():

pragma solidity ^0.8.13;

struct AccountLiquidityLocalVars {
uint sumCollateral;
uint sumBorrowPlusEffects;
uint cTokenBalance;
uint borrowBalance;
uint exchangeRateMantissa;
uint oraclePriceMantissa;
Exp collateralFactor;
Exp exchangeRate;
Exp oraclePrice;
Exp tokensToDenom;
}

// …

function getHypotheticalAccountLiquidityInternal(
address account,
CToken cTokenModify,
uint redeemTokens,
uint borrowAmount) internal view returns (Error, uint, uint) {

AccountLiquidityLocalVars memory vars;
uint oErr;

CToken[] memory assets = accountAssets[account];
for (uint i = 0; i < assets.length; i++) {
    CToken asset = assets[i];

    (oErr, vars.cTokenBalance, vars.borrowBalance, vars.exchangeRateMantissa) = asset.getAccountSnapshot(account);
    if (oErr != 0) {
        return (Error.SNAPSHOT_ERROR, 0, 0);
    }
    vars.collateralFactor = Exp({mantissa: markets[address(asset)].collateralFactorMantissa});
    vars.exchangeRate = Exp({mantissa: vars.exchangeRateMantissa});

    vars.oraclePriceMantissa = oracle.getUnderlyingPrice(asset);
    if (vars.oraclePriceMantissa == 0) {
        return (Error.PRICE_ERROR, 0, 0);
    }
    vars.oraclePrice = Exp({mantissa: vars.oraclePriceMantissa});
    vars.tokensToDenom = mul_(mul_(vars.collateralFactor, vars.exchangeRate), vars.oraclePrice);
    vars.sumCollateral = mul_ScalarTruncateAddUInt(vars.tokensToDenom, vars.cTokenBalance, vars.sumCollateral);
    vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.oraclePrice, vars.borrowBalance, vars.sumBorrowPlusEffects);

    if (asset == cTokenModify) {
        vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.tokensToDenom, redeemTokens, vars.sumBorrowPlusEffects);
        vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.oraclePrice, borrowAmount, vars.sumBorrowPlusEffects);
    }
}

if (vars.sumCollateral > vars.sumBorrowPlusEffects) {
    return (Error.NO_ERROR, vars.sumCollateral - vars.sumBorrowPlusEffects, 0);
} else {
    return (Error.NO_ERROR, 0, vars.sumBorrowPlusEffects - vars.sumCollateral);
}

}

Здесь мы видим, что основная логика этой функции ограничена циклом for. Это указывает на то, что ликвидность счета вычисляется путем перебора всех рынков, в которых участвует счет. Другими словами, все активы, заимствованные счетом или использованные в качестве залога, учитываются при расчете ликвидности счета.

Напомним, из нашего предыдущего сообщения в блоге, что cTokenBalance — это сумма базового актива, выставленная пользователем в качестве залога. В этом примере мы также можем увидеть borrowBalance и какой-то загадочный exchangeRateMantissa, которые возвращаются из getAccountSnapshot().

В нашем обсуждении обобщаемой переменной exchangeRate в нашем предыдущем сообщении в блоге

Произвольный курс, [который] может увеличить количество отчеканенных токенов, если exchangeRate < 1, и уменьшить сумму, если exchangeRate > 1

Это справедливо для exchangeRateMantissa, который представляет собой обменный курс между cToken к базовому активу.

Как мы видим в нашем примере, первое, что сделает контролер после получения 3 параметров, о которых мы упоминали выше, — это получит collateralFactor для конкретного рынка, который он в настоящее время итерирует. Эта collateralFactor является показателем того, сколько пользователь может занять под свой залог. Исходя из этого определения, мы можем предположить, что сумма, которую пользователь может занять под каждый внесенный им залог, будет отличаться для каждого типа залога.

Основная причина, по которой эта сумма различается между активами, заключается в том, что каждый актив имеет свой собственный «риск» в глазах протокола, который обычно сводится к тому, насколько волатильной может быть стоимость актива с течением времени.

Управление Compound меняет залоговые факторы в зависимости от рыночной конъюнктуры, но в любой момент времени их залоговый коэффициент не может превышать 0,9 — в лучшем случае можно занять 90% от внесенного залога:

pragma solidity ^0.8.13;

uint internal constant collateralFactorMaxMantissa = 0.9e18; // 0.9

Затем мы видим вызов oracle.getUnderlyingPrice(asset) который вызывает внешний контракт под названием Oracle.

Оракулы — очаровательные звери, которые заслуживают отдельного поста в блоге (следите за обновлениями!). Для краткости мы сейчас объясним, что оракулы — это контракты, используемые в протоколах кредитования для получения цены какого-либо актива, выраженного в какой-либо общей валюте (обычно USD, ETH или стейблкоин, используемый протоколом).

Теперь мы рассмотрели все факторы, влияющие на работоспособность позиции для одного рынка, поэтому мы запишем уравнение для расчета AccountLiquidity счета одного рынка:

Примечание: При использовании Compound цены на активы выражены в долларах США (USD).

Это довольно длинный список переменных, но если вы попытаетесь вспомнить раздел Compound нашего поста «Share Tokens», вы увидите, что выражение

Просто представляет стоимость базового актива cTokens пользователя.

Кроме того, переменная borrowBalance_{user}, как вы можете видеть здесь, представляет собой общий баланс заемных активов пользователя, включая начисленные на него проценты.

Теперь мы подошли к точке, где имеет смысл следующее альтернативное уравнение AccountLiquidity:

Создатель

Еще одним протоколом, устанавливающим порог для ликвидации позиций с недостаточным обеспечением, является Maker.

Давайте рассмотрим два контракта, которые протокол использует для обработки ликвидаций:

  • Dog: развертывается после перехода на ликвидацию 2.0 (как ее называет управление Maker). Ликвидационной функцией здесь является bark().
  • Cat: ликвидации 1.2, bite().
  • grab(): Контракт с НДС, используемый в качестве способа ликвидации перед развертыванием контракта cat.

Давайте посмотрим на фрагмент из bite():

pragma solidity ^0.8.13;

function bite(bytes32 ilk, address urn) external returns (uint id) {
(, uint rate, uint spot) = vat.ilks(ilk);
(uint ink, uint art) = vat.urns(ilk, urn);

require(live == 1, "Cat/not-live");
require(spot > 0 && mul(ink, spot) < mul(art, rate), "Cat/not-unsafe");

И похожий фрагмент из bark():

pragma solidity ^0.8.13;

function bark(bytes32 ilk, address urn, address kpr) external returns (uint256 id) {
require(live == 1, «Dog/not-live»);

(uint256 ink, uint256 art) = vat.urns(ilk, urn);
Ilk memory milk = ilks[ilk];
uint256 dart;
uint256 rate;
uint256 dust;
{
    uint256 spot;
    (,rate, spot,, dust) = vat.ilks(ilk);
    require(spot > 0 && mul(ink, spot) < mul(art, rate), "Dog/not-unsafe");

Вы можете заметить, что оба имеют идентичные небезопасные сообщения. Следовательно, требования к безопасности хранилища для каждой функции ликвидации одинаковы, и это можно выразить в следующем уравнении:

Который мы можем использовать для определения неравенства, которое должно сохраняться, чтобы Vault (имя Maker для позиции) по-прежнему был в безопасности:

Написано более приятным способом:

Мы рекомендуем нашим читателям обратиться к глоссарию MakerDAO, который расширяет предоставленную нами информацию о различных именах переменных и терминах в экосистеме Maker.

Кроме того, вы можете доверять тому, что мы опишем здесь:

• spot_{ilk} используется в этом неравенстве как цена залога, выраженная в DAI и деленная на коэффициент ликвидации залога (определяемый управлением).

• ink_{urn} – залоговый остаток позиции.

• rate_{ilk} – накопленная задолженность по определенному виду залога. При умножении art_{urn}, нормализованную сумму долга, заимствованную на позицию, мы можем получить общий долг в DAI.

Чтобы упростить то, что мы только что рассмотрели без номенклатуры Maker, мы скажем:

Примечание: Мейкер решил деноминировать стоимость залога и долга в DAI — собственном стейблкоине протокола.

AAVE V2 — Фактор здоровья

AAVE V2 также определила свой собственный порог HealthFactor. Пользователь со значением фактора здоровья H_{f} < 1 может быть ликвидирован.

Здесь определено:

pragma solidity ^0.8.13;

vars.healthFactor = calculateHealthFactorFromBalances(
vars.totalCollateralInETH,
vars.totalDebtInETH,
vars.avgLiquidationThreshold
);

// …

/**

  • @dev Calculates the health factor from the corresponding balances
  • @param totalCollateralInETH The total collateral in ETH
  • @param totalDebtInETH The total debt in ETH
  • @param liquidationThreshold The avg liquidation threshold
  • @return The health factor calculated from the balances provided
    **/
    function calculateHealthFactorFromBalances(
    uint256 totalCollateralInETH,
    uint256 totalDebtInETH,
    uint256 liquidationThreshold
    ) internal pure returns (uint256) {
    if (totalDebtInETH == 0) return uint256(-1); return (totalCollateralInETH.percentMul(liquidationThreshold)).wadDiv(totalDebtInETH);
    }

Очевидно, что если у пользователя нет долгов, его позиция не может быть ликвидирована, поэтому healthFactor по умолчанию имеет type(uint256).max.

В противном случае HealthFactor определяется как:

Примечание: AAVE V2 деноминирует стоимость их залога и долга в ETH

В то время как LiquidationThreshold определяется независимо для каждого актива по управлению, задача, в настоящее время делегированная Gauntlet, которая предоставляет все параметры риска для протокола, включая LiquidationThresholds LiquidationThreshold.

Анализ неплатежеспособной позиции

Теперь, когда мы рассмотрели концепцию плохого долга, мы приведем реальный пример, чтобы понять его значение

Позиция, которую мы рассмотрим, — это следующая учетная запись на AAVE V2: 0x227cAa7eF6D955A92F483dB2BD01172997A1a623.

Давайте начнем с изучения его текущей ситуации, вызвав функцию getUserAccountData в протоколе кредитования AAVE V2:

Теперь давайте разберем то, что мы видим выше, чтобы оценить, насколько сильно под водой находится эта позиция:

  • totalDebtETH 17.83508595148699ETH
  • totalCollateralETH 0.013596360502551568 ETH

Это все, что нам нужно, чтобы понять, что эта позиция находится в беде — стоимость залога составляет микрочасть долга, который он понес сам.

Но как позиции дошли до такого состояния?

Чтобы ответить на этот вопрос, мы можем проверить последние операции, которые этот пользователь выполнил на AAVE:

Похоже, все было хорошо до 13514857 блока, в котором пользователь позаимствовал некоторые активы у AAVE. Давайте посмотрим, что они сделали:

Должник занял 700 000 MANA, и быстрая проверка цены MANA в долларах США покажет, что цена была:

0,00032838 ETH за единицу MANA.

Итак, с помощью простого умножения мы знаем, что этот пользователь увеличил свой долг перед протоколом на:

0,00032838 * 700000 = 229,866 ETH

Также стоит ознакомиться с ценой ETH в долларах США в этом блоке здесь, которая составляет 4 417,40 долларов США.

Обратите внимание, что операция депозита происходит в 13517657 на изображении выше, всего через пару часов после заимствования. Давайте посмотрим, пошатнуло ли что-то на рынке доверие нашего пользователя:

➜ ~ cast call -b 13517657 0xA50ba011c48153De246E5192C8f9258A2ba79Ca9 «getAssetPrice(address)» 0x0F5D2fB29fb7d3CFeE444a200298f468908cC942
0x000000000000000000000000000000000000000000000000000131d14dce4400

Выше приведен вызов RPC, отправленный в AAVE V2 Price Oracle, чтобы получить значение 1 единицы MANA в wei для указанного блока.

Если мы конвертируем вышеупомянутую цену с этими данными, мы увидим, что произошло:

0,00033625 * 700000 = 235,375 ETH

Всего за несколько часов возникший долг составил ~ 5,5 ETH на сумму ~ 24 000 долларов США. Ой.

Поскольку мы знаем конец истории этой позиции, мы знаем, что в какой-то момент она была ликвидируемой, поэтому давайте проверим звонки в liquidationCall, в которых использовался адрес этого пользователя:

select
evt_block_number,
collateralAsset,
debtAsset,
debtToCover,
liquidatedCollateralAmount,
liquidator
from
aave_v2_ethereum.LendingPool_evt_LiquidationCall
where
user = from_hex(‘0x227cAa7eF6D955A92F483dB2BD01172997A1a623’)
order by
evt_block_number desc;

Не стесняйтесь выполнять приведенный выше запрос в Dune Analytics.

Как только мы найдем первое событие ликвидации, мы сможем понять, почему пользователь депонировал активы вскоре после заимствования:

+——————+———————————————+———————————————+————————-+—————————-+———————————————+
| evt_block_number | collateralAsset | debtAsset | debtToCover | liquidatedCollateralAmount | liquidator |
+——————+———————————————+———————————————+————————-+—————————-+———————————————+
| 13520838 | 0x6B175474E89094C44DA98B954EEDEAC495271D0F | 0x0F5D2FB29FB7D3CFEE444A200298F468908CC942 | 17919685927295406794873 | 58271102282974799175987 | 0xB2B3D5B6215D4FB23BF8DD642D385C4B44AADB2A |
+——————+———————————————+———————————————+————————-+—————————-+———————————————+

Здесь мы видим, что первая ликвидация происходит на 13520838. Эта ликвидация произошла еще до того, как пользователь внес свои средства (~ 7 минут до транзакции депозита).

Затем между блоками 1352083813522070 произошла цепочка небольших ликвидаций, которые в итоге стоили довольно дорого:

select
count(*) as num_liquidations
from
aave_v2_ethereum.LendingPool_evt_LiquidationCall
where
user = from_hex(‘0x227cAa7eF6D955A92F483dB2BD01172997A1a623’)
and evt_block_number <= 13522070 and evt_block_number >= 13520838

+ - - - - - - - - - +
| num_liquidations |
+ - - - - - - - - - +
| 87 |
+ - - - - - - - - - +

Проверим все виды залоговых активов, которые были изъяты у пользователя ликвидаторами между этими блоками:

select
SUM(liquidatedCollateralAmount) as amountSeized,
collateralAsset
from
aave_v2_ethereum.LendingPool_evt_LiquidationCall
where
user = from_hex(‘0x227cAa7eF6D955A92F483dB2BD01172997A1a623’)
and evt_block_number <= 13522070 and evt_block_number >= 13520838
group by collateralAsset

Мы видим только 2 актива, DAI (стейблкоин) и ETH.

+—————————+———————————————+
| amountSeized | collateralAsset |
+—————————+———————————————+
| 387663228503220484547359 | 0x6B175474E89094C44DA98B954EEDEAC495271D0F |
+—————————+———————————————+
| 499940913071713798854 | 0xC02AAA39B223FE8D0A0E5C4F27EAD9083C756CC2 |
+—————————+———————————————+

И их суммы:

  • ~50 ETH
  • ~387 663 DAI

Можно спросить, почему ликвидация происходила такими маленькими порциями?

Что ж, когда такие огромные позиции, как эта, сразу ликвидируются, рынок интерпретирует такое крупное приобретение залога как сигнал на продажу для этих типов активов. Помните: активы, приобретенные при ликвидации, приобретаются со скидкой в соответствии с политикой стимулирования ликвидаторов протокола.

Одна крупная ликвидация может создать снежный ком ликвидаций по мере роста давления со стороны продавцов. Другие участники рынка также могут продать свои активы, что приведет к дальнейшему «сбросу» цены актива, что, в свою очередь, приведет к еще большему количеству ликвидаций других позиций в протоколе.

Поэтому протоколы обычно ограничивают объем активов, на которые может быть наложен арест при одной ликвидации. Версию AAVE этого ограничения в виде переменной можно увидеть ниже:

pragma solidity ^0.8.13;

// from …

uint256 internal constant LIQUIDATION_CLOSE_FACTOR_PERCENT = 5000;

function liquidationCall(
address collateralAsset,
address debtAsset,
address user,
uint256 debtToCover,
bool receiveAToken
) external override returns (uint256, string memory) {

// ...

vars.maxLiquidatableDebt = vars.userStableDebt.add(vars.userVariableDebt).percentMul(
  LIQUIDATION_CLOSE_FACTOR_PERCENT
);

// ...

Как мы видим, лимитный процент составляет 50%, а это значит, что за одну ликвидацию допускается погасить только половину долга позиции.

У ликвидаторов также есть стимул разбивать свои ликвидации на более мелкие части. Если на момент ликвидации на рынке не было достаточной ликвидности для предоставления залогового актива ликвидатору в полном объеме. Разбивая ликвидацию на более мелкие части, ликвидаторы имеют больше шансов получить ликвидные активы и получить прибыль от их ликвидации.

Кроме того, если на рынке не было достаточной ликвидности для долгового актива, то ликвидатору может быть дорого даже приобрести этот актив в первую очередь для погашения долга пользователя с недостаточным обеспечением.

Наконец, представьте, что вы пытаетесь ликвидировать огромное количество какого-то токена, не владея этой суммой. Если вы пойдете на DEX и попытаетесь обменять WETH или любой другой актив на этот токен, вы можете столкнуться с настолько высокими комиссиями за газ, что они сделают вашу ликвидацию невыгодной.

Возвращаясь к нашему примеру, для проверки параметров позиции после цепочки ликвидаций нам нужно разобрать данные, возвращенные нам из getUserAccountData:

from io import BytesIO
from binascii import unhexlify
from dataclasses import dataclass

@dataclass(frozen=True)
class UserAccountData:
totalCollateralETH: int
totalDebtETH: int
availableBorrowsETH: int
currentLiquidationThreshold: int
ltv: int
healthFactor: int

def parse_user_account_data(uacd: str) -> UserAccountData:
uacd_bytes = unhexlify(uacd[2:])

assert len(uacd_bytes) == 192

uacd_bytes = BytesIO(uacd_bytes)
total_collateral_eth = int.from_bytes(bytes=uacd_bytes.read(32), byteorder="big", signed=False)
total_debt_eth = int.from_bytes(bytes=uacd_bytes.read(32), byteorder="big", signed=False)
available_borrows_eth = int.from_bytes(bytes=uacd_bytes.read(32), byteorder="big", signed=False)
current_liquidation_threshold = int.from_bytes(bytes=uacd_bytes.read(32), byteorder="big", signed=False)
ltv = int.from_bytes(bytes=uacd_bytes.read(32), byteorder="big", signed=False)
health_factor = int.from_bytes(bytes=uacd_bytes.read(32), byteorder="big", signed=False)

return UserAccountData(
    totalCollateralETH=total_collateral_eth,
    totalDebtETH=total_debt_eth,
    availableBorrowsETH=available_borrows_eth,
    currentLiquidationThreshold=current_liquidation_threshold,
    ltv=ltv,
    healthFactor=health_factor,
)

Затем мы запрашиваем цепочку с помощью cast:

➜ ~ cast call -b 13522070 0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9 «getUserAccountData(address)» 0x227cAa7eF6D955A92F483dB2BD01172997A1a623
0x000000000000000000000000000000000000000000000000085b5b5e846685f4000000000000000000000000000000000000000000000002743544e203a3e4ae00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001f710000000000000000000000000000000000000000000000000000000000001d9500000000000000000000000000000000000000000000000000260a45667b706b

И, наконец, проанализируйте вывод:

parse_user_account_data(‘0x000000000000000000000000000000000000000000000000085b5b5e846685f4000000000000000000000000000000000000000000000002743544e203a3e4ae00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001f710000000000000000000000000000000000000000
000000000000000000001d9500000000000000000000000000000000000000000000000000260a45667b706b’)
UserAccountData(totalCollateralETH=602175436690458100, totalDebtETH=45267162967098778798, availableBorrowsETH=0, currentLiquidationThreshold=8049, ltv=7573, healthFactor=10707342303391851)

Здесь мы видим влияние ликвидаций на позицию: залога почти не осталось, если быть точным, ~ 0,6 ETH. Но как быть с долгом? 45.26716296709878 ETH!

И какова цена MANA на этой высоте блока?

➜ ~ cast call -b 13522070 0xA50ba011c48153De246E5192C8f9258A2ba79Ca9 «getAssetPrice(address)» 0x0F5D2fB29fb7d3CFeE444a200298f468908cC942
0x00000000000000000000000000000000000000000000000000031015cc1da8f2

0.000862110734985458 ETH!

Если вы помните, наш пользователь одолжил MANA всего за несколько часов до этого по цене 0,00032838 ETH. Это эквивалентно открытию короткой позиции по акции, которая взлетает в 2,65 раза — Oof ? ?!

Эти ликвидаторы не смогли своевременно ликвидировать всю позицию до того, как цена MANA упала слишком сильно, чтобы процесс был прибыльным, и мы остались с неплатежеспособной позицией.

Теперь мы можем оценить важность эффективного порога ликвидности для предотвращения безнадежных долгов из протоколов.

Обобщение

Хотя мы не можем однозначно сказать, что существует одно уравнение для определения порога ликвидности позиции, мы, безусловно, можем увидеть сходство между протоколами:

  • Все протоколы определяют свои пороговые значения как некоторую функцию обеспечения: долга (будь то соотношение или разница)
  • Все протоколы оставляют некоторое пространство для управления для определения значения параметра риска на залог в ответ на изменения рыночных условий, поскольку некоторые активы более волатильны, чем другие.
  • Все протоколы деноминируют цены на залоговое обеспечение и долговые обязательства с помощью оракула в общепринятой валюте (например, ETH, USD, DAI).

Мы видели, что Maker и AAVE решили использовать одно и то же уравнение для представления безопасности позиции:

Часть 3: Вознаграждения

Мы рассмотрим, как протоколы кредитования DeFi стимулируют участников с помощью вознаграждений.

В предыдущих сообщениях блога мы рассмотрели две ключевые концепции кредитования DeFi: кредитование, заимствование и ликвидация. В этом последнем выпуске мы рассмотрим, как протоколы используют вознаграждения для стимулирования вкладчиков.

Почему награды?

Мы рассмотрели некоторые из основных протоколов кредитования, таких как AAVE, Compound и Maker, а также более мелкие, такие как Euler. Эти протоколы не могут работать без активов вкладчиков. Например, aToken от AAVE и cToken от Compound требуют большой ликвидности для заимствования. На момент написания статьи только aToken USDT от AAVE насчитывал более 114 миллионов USDT. Протоколы конкурируют за активы вкладчиков, предлагая здоровые и стабильные финансовые экосистемы, а также финансовые стимулы, включая вознаграждения.

Маркеры протокола

Протоколы часто выдают вознаграждения в виде собственных токенов протокола, которые примерно представляют собой доли собственности в протоколе. Держатели токенов протокола, как правило, имеют право участвовать в управлении, голосуя за предложения, но управление не входит в рамки этого поста — мы могли бы сделать об этом целую серию.

С точки зрения вкладчика стратегию распределения депозитов для заработка токенов протокола часто называют майнингом ликвидности. Некоторые протоколы позволяют держателям токенов протокола размещать свои токены для получения дополнительных вознаграждений.

В следующих разделах мы рассмотрим, как токены протокола распределяются в качестве вознаграждения в трех интересных протоколах: Liquity, AAVE V2 и Compound. В заключение мы предлагаем обобщение концепций, иллюстрирующее, как три реализации имеют одну и ту же базовую концепцию.

Ликвити (LQTY)

Liquity предоставляет беспроцентные кредиты, обеспеченные эфиром, выплачиваемые в стейблкоине LUSD. Кредиты должны поддерживать фиксированный минимальный коэффициент обеспечения в размере 110%. Протокол не требует управления и является неизменным. Токен протокола LQTY дает своим держателям право на долю сборов, заработанных протоколом.

Напомним из нашего предыдущего поста, что протоколы позволяют третьим лицам наложить арест на залоговое имущество при ликвидации, когда остаток займа пользователя превышает порог, определенный протоколом, что может привести к неплатежеспособности. Эти ликвидации не автоматизированы, а делегированы опытным пользователям DeFi, которые разрабатывают смарт-контракты и операции для прибыльной ликвидации пользователей.

Liquity использует другой подход. Вместо того, чтобы полагаться на третьи стороны, он поддерживает пул стабильности с LUSD. Когда кредиты становятся ликвидируемыми, LUSD из пула стабильности используется для погашения кредита. Этот LUSD сжигается, а залог эфира распределяется между пользователями, которые предоставляют LUSD пулу.

Ликвидация на Liquity

Из-за минимального коэффициента обеспечения 110% большинство ликвидаций выгодны для пула стабильности, поскольку участники получают больше стоимости в ETH от залога, чем теряют от сжигания LUSD. Для получения дополнительной информации о логике ликвидации Liquity см. их документацию.

Liquity дополнительно стимулирует участие в пуле стабильности за счет выпуска вознаграждений в виде токенов протокола (LQTY). Рассмотрим логику распределения:

pragma solidity ^0.8.13;

function _getLQTYGainFromSnapshots(uint initialStake, Snapshots memory snapshots) internal view returns (uint) {
/*
* Grab the sum ‘G’ from the epoch at which the stake was made. The LQTY gain may span up to one scale change.
* If it does, the second portion of the LQTY gain is scaled by 1e9.
* If the gain spans no scale change, the second portion will be 0.
*/
uint128 epochSnapshot = snapshots.epoch;
uint128 scaleSnapshot = snapshots.scale;
uint G_Snapshot = snapshots.G;
uint P_Snapshot = snapshots.P;

uint firstPortion = epochToScaleToG[epochSnapshot][scaleSnapshot].sub(G_Snapshot);
uint secondPortion = epochToScaleToG[epochSnapshot][scaleSnapshot.add(1)].div(SCALE_FACTOR);

uint LQTYGain = initialStake.mul(firstPortion.add(secondPortion)).div(P_Snapshot).div(DECIMAL_PRECISION);

return LQTYGain;

}

Функция опирается на два ключевых понятия:

  • G представляет собой глобальный аккумулятор, отражающий совокупную сумму вознаграждений LQTY, выпущенных протоколом.
  • P — это глобальный накопитель продуктов, который отслеживает общее произведение сложных ставок по депозитам с течением времени. Он уменьшается при потерях LUSD для участников пула стабильности от ликвидаций.

Вот как работает функция:

  1. Извлеките соответствующие значения моментального снимка, которые включают epochscaleG и P. Эти значения берутся с того момента, когда пользователь в последний раз обновлял свою ставку.
  2. Вычислите firstPortion, вычтя значение G_Snapshot из значения G соответствующей эпохи и масштаба. Это представляет собой прирост LQTY, который произошел в том же масштабе.
  3. Вычислите secondPart, разделив значение G следующей scale на SCALE_FACTOR. Это представляет собой выигрыш LQTY, который произошел при изменении scale. Если scale не изменится, вторая порция будет 0.
  4. firstPortion и secondPortion вместе, чтобы получить общий выигрыш LQTY.
  5. Умножьте общий выигрыш LQTY на первоначальную ставку пользователя.
  6. Разделите полученный результат на P_Snapshot (пользовательский снимок товара P). Это дает вам окончательную награду LQTY для пользователя.

Абстрагируясь от некоторых технических деталей и подстраиваясь под ноль для обозначения значений переменных во время моментального снимка, формулу вознаграждения можно резюмировать следующим образом:

Aave V2 (AAVE и stkAAVE)

Токен протокола AAVE предоставляет полномочия по управлению, позволяя держателям участвовать в принятии решений, например, при добавлении новых токенов.

Есть два способа заработать награды AAVE:

  1. Предоставление ликвидности: Предоставляя ликвидность Aave или участвуя в пулах активов AAVE на DEX, пользователи зарабатывают токены AAVE в качестве стимулов, а также долю комиссий платформы.
  2. Стейкинг AAVE: Размещая токены AAVE в контракте Staked AAVE (stkAAVE), пользователи получают токены stkAAVE и зарабатывают вознаграждения AAVE. Развернутый контракт на самом деле является прокси-сервером EIP-1967 для реальной реализации Staked AAVE. stkAAVE также является токеном, совместимым с ERC20, поэтому мы можем вызвать его функцию balanceOf(address) в любое время, чтобы увидеть баланс токенов stkAAVE пользователя.

Давайте рассмотрим, как рассчитываются вознаграждения за стейкинг:

pragma solidity ^0.8.13;

contract StakedTokenV2 {

// …

IERC20 public immutable STAKED_TOKEN;
IERC20 public immutable REWARD_TOKEN;

// …

mapping(address => uint256) public stakerRewardsToClaim;

// …

function getTotalRewardsBalance(address staker) external view returns (uint256) {
DistributionTypes.UserStakeInput[] memory userStakeInputs =
new DistributionTypes.UserStakeInput;
userStakeInputs[0] = DistributionTypes.UserStakeInput({
underlyingAsset: address(this),
stakedByUser: balanceOf(staker),
totalStaked: totalSupply()
});
return stakerRewardsToClaim[staker].add(_getUnclaimedRewards(staker, userStakeInputs));
}
}

Как вы можете видеть, последняя общая сумма ожидающих вознаграждений для пользователя не хранится напрямую. Сопоставление stakerRewardsToClaim сохраняет только последнее значение — контрольную точку. Чтобы понять, как рассчитывается общий баланс вознаграждений, мы рассмотрим _getUnclaimedRewards:

pragma solidity 0.8.13;

contract AaveDistributionManager {

// …

struct AssetData {
uint128 emissionPerSecond;
uint128 lastUpdateTimestamp;
uint256 index;
mapping(address => uint256) users;
}

// …

mapping(address => AssetData) public assets;

// …

function _getUnclaimedRewards(address user, DistributionTypes.UserStakeInput[] memory stakes)
internal
view
returns (uint256)
{
uint256 accruedRewards = 0;

for (uint256 i = 0; i < stakes.length; i++) {
  AssetData storage assetConfig = assets[stakes[i].underlyingAsset];
  uint256 assetIndex =
    _getAssetIndex(
      assetConfig.index,
      assetConfig.emissionPerSecond,
      assetConfig.lastUpdateTimestamp,
      stakes[i].totalStaked
    );

  accruedRewards = accruedRewards.add(
    _getRewards(stakes[i].stakedByUser, assetIndex, assetConfig.users[user])
  );
}
return accruedRewards;

}
}

Невостребованные вознаграждения критически зависят от assetIndex. Это классическая техника распространения на основе моментальных снимков, которую мы обсуждали в первом посте серии. Вот как это рассчитано:

pragma solidity 0.8.13;

contract AaveDistributionManager {

// …

uint256 public immutable DISTRIBUTION_END;

// …

uint8 public constant PRECISION = 18;

// …

function _getAssetIndex(
uint256 currentIndex,
uint256 emissionPerSecond,
uint128 lastUpdateTimestamp,
uint256 totalBalance
) internal view returns (uint256) {
if (
emissionPerSecond == 0 ||
totalBalance == 0 ||
lastUpdateTimestamp == block.timestamp ||
lastUpdateTimestamp >= DISTRIBUTION_END
) {
return currentIndex;
}

uint256 currentTimestamp =
  block.timestamp > DISTRIBUTION_END ? DISTRIBUTION_END : block.timestamp;
uint256 timeDelta = currentTimestamp.sub(lastUpdateTimestamp);
return
  emissionPerSecond.mul(timeDelta).mul(10**uint256(PRECISION)).div(totalBalance).add(
    currentIndex
  );

}
}

Определение assetIndex работает следующим образом:

  • Возвращает currentIndex, если выполняется одно из следующих условий:
    — lastUpdateTimestamp = block.timestamp
    — lastUpdateTimestamp >= DISTRIBUTION_END
    — emissionPerSecond = 0
    — totalSupply = 0
  • В противном случае выполните следующие вычисления:
  • Рассчитать timeDelta:
    — Если block.timestamp <= DISTRIBUTION_END : timeDelta = block.timestamp— lastUpdateTimestamp
    — Иначе: timeDelta = DISTRIBUTION_END — lastUpdateTimestamp
  • Вычислить новый assetIndex = currentIndex + (emissionPerSecond * timeDelta) / (totalSupply)

Приведенная выше логика обновляет индекс, добавляя правильную сумму на основе временной дельты и скорости выбросов. Наконец, мы можем рассмотреть, как на самом деле распределяются награды AAVE:

pragma solidity 0.8.13;

contract AaveDistributionManager {

function _getRewards(
uint256 principalUserBalance,
uint256 reserveIndex,
uint256 userIndex
) internal pure returns (uint256) {
return principalUserBalance.mul(reserveIndex.sub(userIndex)).div(10**uint256(PRECISION));
}

}

Основные параметры:

  • principalUserBalance: первоначальный депозит токена пользователя в стейкинге. Аналогично initialStake в Liquity выше.
  • reserveIndex: текущее значение assetIndex, представляющее прогрессию распределения вознаграждения.
  • userIndex: значение reserveIndex на момент стейкинга пользователя.

Окончательное уравнение выглядит следующим образом:

Компаунд (COMP)

Вознаграждение Compound за протокол токенов COMP очень похоже на AAVE. Мы проиллюстрируем это функцией, которую Compound использует для распределения вознаграждений между поставщиками. Аналогичная функция есть и для заемщиков, но она суммирует суммы, взятые взаймы, а не поставщика.

contract ComptrollerG7 {

// …

function distributeSupplierComp(address cToken, address supplier) internal {

CompMarketState storage supplyState = compSupplyState[cToken];
uint supplyIndex = supplyState.index;
uint supplierIndex = compSupplierIndex[cToken][supplier];
compSupplierIndex[cToken][supplier] = supplyIndex;

if (supplierIndex == 0 && supplyIndex >= compInitialIndex) {
    supplierIndex = compInitialIndex;
}

Double memory deltaIndex = Double({mantissa: sub_(supplyIndex, supplierIndex)});

uint supplierTokens = CToken(cToken).balanceOf(supplier);

uint supplierDelta = mul_(supplierTokens, deltaIndex);

uint supplierAccrued = add_(compAccrued[supplier], supplierDelta);
compAccrued[supplier] = supplierAccrued;

emit DistributedSupplierComp(CToken(cToken), supplier, supplierDelta, supplyIndex);

}
}

Основные параметры:

  • supplierTokens: Депозит пользователя.
  • supplyIndex: текущее значение индекса предложения.
  • supplierIndex: значение индекса предложения, когда пользователь депонировал свои токены.

И снова восстанавливаем знакомое уравнение:

Обобщение

Приведенные выше уравнения можно свести к одной и той же логике: мы умножаем количество токенов пользователя на совокупную сумму, которая представляет собой сумму вознаграждений, заработанных за токен в стейкинге за период времени, в течение которого пользователь размещал свои токены.

Реализации различаются, но в каждом случае протокол вознаграждает вкладчиков токенами протокола в зависимости от того, сколько они внесли и как долго они остаются на депозите.

Обобщение начисления вознаграждений

Источник