This post is a collaboration with Asianometry. Asianometry is a YouTube channel on Asian history and contemporary matters. I am collaborating with Asianometry in this post because the Perpertual Protocol is founded by a Taiwanese team and we want to cover not just the technical aspect of this protocol, but also Taiwan’s DeFi ecosystem. You can see Asianometry’s introduction to Taiwan’s DeFi ecosystem and Perpetual Protocol below.
What is a perpetual contract?
A perpetual contract is similar to a futures contract. It does not have an expiry or settlement so it can be held indefinitely without rolling over to a new contract. The trading of perpetual contracts is based on an underlying index price that consists of the asset’s average price and they are often traded at a price that is very close to the spot price.
Traders usually trade perpetuals with leverage. They are required to pay an initial margin to open a leveraged position. Let’s say you want to open a 10 BTC long position with 10x leverage, you will need to put 1 BTC as the initial margin, which acts as the collateral.
There is a minimum amount of collateral a trader must hold to keep his position open and it is called the maintenance margin. If a trader’s maintenance margin falls below the maintenance margin, the trader will either be asked to add more funds to maintain the maintenance margin or he will be liquidated.
Traders trade against other traders on the other side of a trade. In order to incentivize traders to take the unpopular side of a trade and to converge the contract price with the spot price, there are regular payments between the buyers and sellers depending on the market sentiment and this is called the funding rate. When the funding rate is positive, long traders have to pay short traders and vice versa. The funding rate is calculated based on the interest rate and the premium. The interest rate is fixed and the premium depends on the price difference between futures and spot markets.
How does the Perpetual Protocol work?
The Perpetual Protocol is built on the xDai chain in order to allow traders to trade with zero gas fees. The xDai chain is an Ethereum sidechain that uses the Proof-of-Stake consensus mechanism, thus it has very low transaction fees (around $0.01 per 500 transactions). Its native token is xDai, a stablecoin with 1:1 value ratio to Dai. Traders can trade with up to 10x leverage on the protocol with Virtual AMMs without counterparties.
What is a Virtual AMM?
A Virtual AMM (vAMM) uses the same constant formula x*y=k
that traditional AMMs use. However, as the AMM is virtual, there is no real asset stored inside the vAMM itself. The real asset is stored in a separate vault that manages all the collateral. Initially, the virtual pool creator sets the variables in the formula inside the vAMM. Let’s say the pool consists of ETH and USDC and the spot price is $3,800. The virtual pool creator sets the initial state as 100 vETH (x) and 380,000 vUSDC (y), and k is equal to 38,000,000.
Trader 0xkowloon deposits 100 USDC into the vault and goes 10x long on ETH, 1,000 vUSDC is credited to 0xkowloon. 0xkowloon’s ETH position after opening the position becomes 100 - 38,000,000 ÷ 381,000 = 0.2624671916
. The new vETH and vUSDC are 99.7375328084 and 381,000 respectively.
Another trader Jon also deposits 100 USDC into the vault to goes 10x long on ETH. The protocol credits 1,000 vUSDC to Jon, which then becomes Jon’s ETH position 99.7375328084 - 99.7375328084 x 381,000 ÷ 382,000 = 0.2610930178
. The new vETH and vUSDC are 99.4764397906 and 382,000 respectively.
0xkowloon closes his position and realizes a profit of 382,000 - 99.4764397906 x 382,000 ÷ (99.4764397906 + 0.2624671916) - 1,000 = 5.2493076658
USDC. The new vETH and vUSDC are 99.7389069823 and 380,994.750692 respectively.
Jon closes his position and loses 380,994.750692 - 99.7389069823 x 380,994.750692 ÷ (99.7389069823 + 0.2610930178) - 1,000 = -5.24930775969
. The new vETH and vUSDC are back to 100 and 38,000,000.
From the example above, we can see that a trader’s profit is another trader’s loss. There are no liquidity providers, but the vault always has enough collateral to pay back every trader due to vAMM’s zero-sum nature.
The key difference between AMM and vAMM is that the value of k can be changed at will even after it is created. k is manually set by the vAMM operator and will eventually be set algorithmically.
Deep dives into vAMM by the team can be found here and here.
Moving your USDC to xDai
Before a trader is able to use the Perpetual Protocol, he first has to move his USDC from the Ethereum network to xDai if you haven’t done so already.
The trader has to deposit USDC into the Deposit Proxy in the Ethereum mainnet, which then is passed downstream to the Root Bridge Contract. The trader’s xDai address (same as his ETH address) will be credited the same amount of USDC.
If the trader already has USDC on xDai, then he can skip this and directly interacts with the clearing house (he does not even need to interact with the proxy and the relayer).
More detailed instructions can be found at https://docs.perp.fi/tutorials/transfer-funds-to-from-layer-2.
Let’s dive into the code
The main logic lives in ClearingHouse.sol
. A clearing house has the following attributes. Only the contract owner can modify them.
initMarginRatio - the initial margin ratio required when opening a position
maintenanceMarginRatio - the margin ratio a trader has to maintain
liquidationFeeRatio - liquidation penalty percentage
A trader can have a position.
struct Position {
SignedDecimal.signedDecimal size;
Decimal.decimal margin;
Decimal.decimal openNotional;
SignedDecimal.signedDecimal lastUpdatedCumulativePremiumFraction;
uint256 liquidityHistoryIndex;
uint256 blockNumber;
}
size - denominated in base asset
margin - isolated margin
openNotional - the quote asset value of position when opening position, it is the cost of the position.
lastUpdatedCumulativePremiumFraction - for calculating funding payment, record at the moment every time when a trader opens/reduces/closes position
liquidityHistoryIndex - last updated index in the liquidity snapshots array
blockNumber - the block number of the last position
Opening a ETH/USDC position
A trader can open a position by calling ClearingHouse#openPosition
. He has to provide the amm (AMM logic address), quote asset amount (USDC), leverage, base asset amount limit (ETH) and side (BUY, SELL).
Initial margin ratio check
It first checks that the initial margin ratio requirement is met.
requireMoreMarginRatio(MixedDecimal.fromDecimal(Decimal.one()).divD(_leverage), initMarginRatio, true);
/*
* In requireMoreMarginRatio, check
* 1 ÷ leverage - initial margin ratio >= 0
*/
int256 remainingMarginRatio = MixedDecimal.fromDecimal(Decimal.one()).divD(_leverage).subD(initMarginRatio).toInt();
require(remainingMarginRatio >= 0, "Margin ratio not meet criteria");
Restriction mode check to prevent attacks
It then checks the AMM is not in restriction mode. The restriction mode is turned on when any underwater position is being closed (having a bad debt and causing the insurance fund to make a loss) or any liquidation happens. In restriction mode, no one can do multiple open/close/liquidate positions in the same block to prevent the protocol from being attacked.
requireNotRestrictionMode(_amm);
function requireNotRestrictionMode(IAmm _amm) private view {
uint256 currentBlock = _blockNumber();
if (currentBlock == ammMap[address(_amm)].lastRestrictionBlock) {
require(getUnadjustedPosition(_amm, _msgSender()).blockNumber != currentBlock, "only one action allowed");
}
}
AMM liquidity update
After all the initial checks, the clearing house gets the trader’s current position’s size (if any).
int256 oldPositionSize = adjustPositionForLiquidityChanged(_amm, trader).size.toInt();
bool isNewPosition = oldPositionSize == 0 ? true : false;
The trader’s position is stored in the mapping ammMap
. The struct AmmMap
has a positionMap
mapping for each trader.
mapping(address => AmmMap) internal ammMap;
Position memory unadjustedPosition = ammMap[address(_amm)].positionMap[_trader];
If the position size is 0, it returns unadjustedPosition
immediately. Otherwise, it checks the AMM’s liquidity history to see if the unadjusted position is already at the latest. If so, it also returns unadjustedPosition
immediately.
If the unadjusted position is not at the latest liquidity snapshot, the clearing house needs to calculate the trader’s position after liquidity migration. To calculate his position, the clearing house has to calculate the change in cumulative notional value (notionalDelta
), add it to the quote asset reserve, and also add it to the base asset reserve after calculating its value in base asset.
/* Calculate change in cumulative notional value */
SignedDecimal.signedDecimal memory notionalDelta = _amm.getCumulativeNotional().subD(lastSnapshot.cumulativeNotional);
/*
* Calculate the value of base asset added
* The formula is
* quote asset reserve x base asset reserve ÷ (quote asset reserve + notional delta) - base asset reserve
*/
Decimal.decimal memory baseAssetWorth = _amm.getInputPriceWithReserves(
notionalDelta.toInt() > 0 ? IAmm.Dir.ADD_TO_AMM : IAmm.Dir.REMOVE_FROM_AMM,
notionalDelta.abs(),
lastSnapshot.quoteAssetReserve,
lastSnapshot.baseAssetReserve
);
updatedOldQuoteReserve = notionalDelta.addD(lastSnapshot.quoteAssetReserve).abs();
updatedOldBaseReserve = lastSnapshot.baseAssetReserve.subD(baseAssetWorth);
/*
* The formula for the trader's notional position on the old
* curve is
* quote asset reserve x base asset reserve ÷ (base asset reserve + trader's position) - quote asset reserve
*
* The formula for the base asset after liquidity migration is
* quote asset reserve x base asset reserve ÷ (quote asset reserve - trader's notional position) - base asset reserve
*/
_position.size = _amm.calcBaseAssetAfterLiquidityMigration(
_position.size,
updatedOldQuoteReserve,
updatedOldBaseReserve
);
The clearing house then sets the trader’s new position after the liquidity migration (the position will be updated again later).
setPosition(_amm, _trader, adjustedPosition);
New position or increasing position
If it is a new position or if the trader is going the same direction with his existing position, the clearing house increases his position. In this example, let’s assume it’s a long position.
positionResp = internalIncreasePosition(
_amm,
_side,
_quoteAssetAmount.mulD(_leverage),
_baseAssetAmountLimit,
_leverage
);
The clearing house calculates the exchanged position size based on the quote asset. The minimum position size is provided to prevent front-running. The formula to get the base asset amount (ETH) is the same as the one above as it also calls getInputPriceWithReserves
.
Decimal.decimal memory baseAssetAmount = getInputPrice(IAmm.Dir.ADD_TO_AMM, _inputAmount);
require(baseAssetAmount.toUint() >= _minOutputAmount.toUint(), "Less than minimal base token");
After getting the price, the AMM updates its reserve. USDC is being added and ETH is being deducted.
quoteAssetReserve = quoteAssetReserve.addD(_quoteAssetAmount);
baseAssetReserve = baseAssetReserve.subD(_baseAssetAmount);
totalPositionSize = totalPositionSize.addD(_baseAssetAmount);
cumulativeNotional = cumulativeNotional.addD(_quoteAssetAmount);
Before updating the AMM’s reserve, the AMM checks the price fluctuation is within the fluctuation limit ratio (fluctuationLimitRatio
). If not, the transaction fails. I believe this is a safety check to prevent wild price swings / attacks.
(Decimal.decimal memory upperLimit, Decimal.decimal memory lowerLimit) = getPriceBoundariesOfLastBlock();
Decimal.decimal memory price = quoteAssetReserve.divD(baseAssetReserve);
require(price.cmp(upperLimit) <= 0 && price.cmp(lowerLimit) >= 0, "price is already over fluctuation limit");
price = quoteAssetReserve.addD(_quoteAssetAmount).divD(baseAssetReserve.subD(_baseAssetAmount));
require(price.cmp(upperLimit) <= 0 && price.cmp(lowerLimit) >= 0, "price is over fluctuation limit");
Now that the AMM has all the reserve data, it creates a new snapshot for record (or update the existing one whose block number is the current block number).
ReserveSnapshot storage latestSnapshot = reserveSnapshots[reserveSnapshots.length - 1];
if (currentBlock == latestSnapshot.blockNumber) {
latestSnapshot.quoteAssetReserve = quoteAssetReserve;
latestSnapshot.baseAssetReserve = baseAssetReserve;
} else {
reserveSnapshots.push(
ReserveSnapshot(quoteAssetReserve, baseAssetReserve, _blockTimestamp(), currentBlock)
);
}
The new size is the trader’s current position’s size plus the exchanged position size.
SignedDecimal.signedDecimal memory newSize = oldPosition.size.addD(positionResp.exchangedPositionSize);
The clearing house checks that the AMM’s new total leveraged position in quote asset (USDC) cannot be greater than the open interest notional cap (if it is set).
SignedDecimal.signedDecimal memory updatedOpenInterestNotional = _amount.addD(openInterestNotionalMap[ammAddr]);
require(updatedOpenInterestNotional.toUint() <= cap || _msgSender() == whitelist, "over limit");
openInterestNotionalMap[ammAddr] = updatedOpenInterestNotional.abs();
It also checks the trader’s holding in base asset (ETH) is not greater than the maximum limit.
Decimal.decimal memory maxHoldingBaseAsset = _amm.getMaxHoldingBaseAsset();
require(newSize.abs().cmp(maxHoldingBaseAsset) <= 0, "hit position size upper bound");
The final step to create a new Position
is to calculate the remain margin, funding payment, the latest cumulative premium fraction and unrealized PnL.
The funding payment is equal to the difference between the latest and last updated cumulative premium fraction times the old position’s size.
fundingPayment = latestCumulativePremiumFraction
.subD(_oldPosition.lastUpdatedCumulativePremiumFraction)
.mulD(_oldPosition.size);
The remain margin is equal to the old position’s margin plus the added margin (deposit) minus the funding payment.
SignedDecimal.signedDecimal memory signedRemainMargin = _marginDelta.subD(fundingPayment).addD(_oldPosition.margin);
The unrealized PnL is calculated based on the AMM’s own spot price.
/*
* The current price in quote asset (USDC) is equal to
* invariant (k) = USDC reserve amount x ETH reserve amount
* ETH amount after position change = ETH reserve amount + new position in ETH
* USDC amount after position change = invariant ÷ ETH amount
* after position change
* USDC sold = USDC amount after position change - USDC pool
* amount
*/
positionNotional = _amm.getOutputPrice(dir, positionSizeAbs);
unrealizedPnl = MixedDecimal.fromDecimal(positionNotional).subD(position.openNotional);
The clearing house has now all the data it needs to move forward with opening the position. It sets the position, makes sure the margin ratio requirement is met (if not new position) and enters restriction mode if the trader has any bad debt.
setPosition(_amm, trader, positionResp.position);
requireMoreMarginRatio(getMarginRatio(_amm, trader), maintenanceMarginRatio, true);
if (positionResp.badDebt.toUint() > 0) {
enterRestrictionMode(_amm);
}
After confirming everything to be valid, the clearing house transfers the quote asset (USDC) from the trader to the clearing house and also charges a fee. The toll fee is transferred to the AMM and the spread is transferred to the insurance fund.
_transferFrom(quoteToken, trader, address(this), positionResp.marginToVault.abs());
Decimal.decimal memory transferredFee = transferFee(trader, _amm, positionResp.exchangedQuoteAssetAmount);
Reverse position
If it is not a new position and the trader is opening a reverse position (has long, open short; has short, open long), the clearing house opens a reverse position.
positionResp = openReversePosition(
_amm,
_side,
trader,
_quoteAssetAmount,
_leverage,
_baseAssetAmountLimit,
false
);
When the new position size is smaller than the old position size
In this scenario, the trader’s position has to be reduced.
The clearing house needs to calculate the position’s realized PnL. The realized PnL is the unrealized PnL times the closed ratio (new position size divided by the old position size).
positionResp.realizedPnl = unrealizedPnl.mulD(positionResp.exchangedPositionSize.abs()).divD(oldPosition.size.abs());
The new remain margin, bad debt, funding payment and latest cumulative premium fraction are calculated based on the new realized PnL.
(
remainMargin,
positionResp.badDebt,
positionResp.fundingPayment,
latestCumulativePremiumFraction
) = calcRemainMarginWithFundingPayment(_amm, oldPosition, positionResp.realizedPnl);
The realized PnL is removed from the unrealized PnL in order to calculate the remain open notional. It has to be greater than 0 or else the transaction fails.
positionResp.unrealizedPnlAfter = unrealizedPnl.subD(positionResp.realizedPnl);
SignedDecimal.signedDecimal memory remainOpenNotional =
oldPosition.size.toInt() > 0
? MixedDecimal.fromDecimal(oldPositionNotional).subD(positionResp.exchangedQuoteAssetAmount).subD(
positionResp.unrealizedPnlAfter
)
: positionResp.unrealizedPnlAfter.addD(oldPositionNotional).subD(
positionResp.exchangedQuoteAssetAmount
);
require(remainOpenNotional.toInt() > 0, "value of openNotional <= 0");
No USDC is transferred to/from the trader in this scenario.
When the new position size is larger than or equal to the old position size
Before opening a reverse position, it closes the trader’s existing position. Everything will be reset to 0 by calling clearPosition
internally.
PositionResp memory closePositionResp = internalClosePosition(_amm, _trader, Decimal.zero());
The old position cannot have any bad debt, otherwise the transaction fails.
require(closePositionResp.badDebt.toUint() == 0, "reduce an underwater position");
The open notional is the difference between the new and the closed position.
Decimal.decimal memory openNotional = _quoteAssetAmount.mulD(_leverage).subD(closePositionResp.exchangedQuoteAssetAmount);
The base asset limit (ETH) is deducted by the closed position’s size if it is greater than it.
if (_baseAssetAmountLimit.toUint() > closePositionResp.exchangedPositionSize.toUint()) {
updatedBaseAssetAmountLimit = _baseAssetAmountLimit.subD(closePositionResp.exchangedPositionSize.abs());
}
A new position is opened.
PositionResp memory increasePositionResp = internalIncreasePosition(_amm, _side, openNotional, updatedBaseAssetAmountLimit, _leverage);
Let’s say the new position is a short position and the closed position is a long position, the new margin to vault is the sum of the closed position’s margin to vault and the increased position’s margin to vault, which should be net negative and results in a withdrawal of USDC.
closePositionResp.marginToVault.addD(increasePositionResp.marginToVault)
If the clearing house does not have enough USDC to return to the trader, it adds the balance shortage to its realized bad debt and draws the difference from the insurance fund to pay the trader.
Decimal.decimal memory totalTokenBalance = _balanceOf(_token, address(this));
if (totalTokenBalance.toUint() < _amount.toUint()) {
Decimal.decimal memory balanceShortage = _amount.subD(totalTokenBalance);
prepaidBadDebt[address(_token)] = prepaidBadDebt[address(_token)].addD(balanceShortage);
insuranceFund.withdraw(_token, balanceShortage);
}
_transfer(_token, _receiver, _amount);
Monitoring your margin
Traders have to monitor their margin to prevent being liquidated. They can get their margin ratio by calling getMarginRatio
. The PnL is based on the AMM’s spot price or the AMM’s time-weighted average price, depending on which one is greater.
(Decimal.decimal memory spotPositionNotional, SignedDecimal.signedDecimal memory spotPricePnl) = (getPositionNotionalAndUnrealizedPnl(_amm, _trader, PnlCalcOption.SPOT_PRICE));
(Decimal.decimal memory twapPositionNotional, SignedDecimal.signedDecimal memory twapPricePnl) = (getPositionNotionalAndUnrealizedPnl(_amm, _trader, PnlCalcOption.TWAP));
(SignedDecimal.signedDecimal memory unrealizedPnl, Decimal.decimal memory positionNotional) = spotPricePnl.toInt() > twapPricePnl.toInt() ? (spotPricePnl, spotPositionNotional) : (twapPricePnl, twapPositionNotional);
return _getMarginRatio(_amm, position, unrealizedPnl, positionNotional);
The margin ratio is equal to the (remain margin - bad debt) ÷ position notional
.
MixedDecimal.fromDecimal(remainMargin).subD(badDebt).divD(_positionNotional);
Adding margin
A trader might want to increase his margin if he is about to get liquidated. He can do so by calling addMargin
. The function updates the trader position’s margin and transfers the additional margin in USDC to the clearing house.
Position memory position = adjustPositionForLiquidityChanged(_amm, trader);
position.margin = position.margin.addD(_addedMargin);
_transferFrom(_amm.quoteAsset(), trader, address(this), _addedMargin);
Removing margin
If the trader is margin ratio is higher than the maintenance margin ratio, he has the option to withdraw USDC from the clearing house by calling removeMargin
as long as his margin ratio is still above requirement.
The clearing house calculates the remain margin based on the removed margin.
SignedDecimal.signedDecimal memory marginDelta = MixedDecimal.fromDecimal(_removedMargin).mulScalar(-1);
(
Decimal.decimal memory remainMargin,
Decimal.decimal memory badDebt,
SignedDecimal.signedDecimal memory fundingPayment,
SignedDecimal.signedDecimal memory latestCumulativePremiumFraction
) = calcRemainMarginWithFundingPayment(_amm, position, marginDelta);
It checks the trader has no bad debt and the his margin ratio is above requirement.
position.margin = remainMargin;
requireMoreMarginRatio(getMarginRatio(_amm, trader), initMarginRatio, true);
If the checks pass, the clearing house transfers the removed margin in USDC to the trader.
withdraw(_amm.quoteAsset(), trader, _removedMargin);
Liquidating a position
In the unfortunate event of not meeting the margin requirement and not adding margin quick enough, liquidators can liquidate a trader’s position in order to make a profit.
In order to make sure the margin ratio is accurate, the AMM gets the base asset (ETH) price from an oracle and checks that it is not too far from the spot price. If it is over the spread limit (10%) and the margin ratio based on the price from the oracle is higher than margin ratio based on the spot price, it uses the price from the oracle.
/* AMM checks whether the price difference is over the spread limit */
Decimal.decimal memory oracleSpreadRatioAbs = MixedDecimal.fromDecimal(marketPrice).subD(oraclePrice).divD(oraclePrice).abs();
return oracleSpreadRatioAbs.toUint() >= 1e17 ? true : false;
/* Use Oracle based margin ratio if it is greater than the spot price based margin ratio */
if (_amm.isOverSpreadLimit()) {
SignedDecimal.signedDecimal memory marginRatioBasedOnOracle = _getMarginRatioBasedOnOracle(_amm, _trader);
if (marginRatioBasedOnOracle.subD(marginRatio).toInt() > 0) {
marginRatio = marginRatioBasedOnOracle;
}
}
Partially liquidating a position
If the clearing house’s partial liquidation ratio is set and the trader’s margin ratio is greater than the liquidation fee ratio, the trader’s position will be partially liquidated. The position to liquidate is equal to the existing position size times the partial liquidation ratio.
/* Get position notional in USDC */
Decimal.decimal memory partiallyLiquidatedPositionNotional =
_amm.getOutputPrice(
position.size.toInt() > 0 ? IAmm.Dir.ADD_TO_AMM : IAmm.Dir.REMOVE_FROM_AMM,
position.size.mulD(partialLiquidationRatio).abs()
);
Then the clearing house opens a reverse position.
positionResp = openReversePosition(
_amm,
position.size.toInt() > 0 ? Side.SELL : Side.BUY,
_trader,
partiallyLiquidatedPositionNotional,
Decimal.one(),
Decimal.zero(),
true
);
The liquidation penalty is equal to the position amount in USDC times the liquidation fee ratio. Half of it goes to the liquidator and another half goes to the insurance fund.
liquidationPenalty = positionResp.exchangedQuoteAssetAmount.mulD(liquidationFeeRatio);
feeToLiquidator = liquidationPenalty.divScalar(2);
feeToInsuranceFund = liquidationPenalty.subD(feeToLiquidator);
The margin is reduced as a penalty is paid.
positionResp.position.margin = positionResp.position.margin.subD(liquidationPenalty);
Fully liquidating a position
If the above conditions are not met, the position is fully liquidated instead. The liquidation penalty is all of the margin.
liquidationPenalty = getPosition(_amm, _trader).margin;
positionResp = internalClosePosition(_amm, _trader, Decimal.zero());
If the remain margin is not enough for liquidation fee, count the difference as bad debt. Or else, send the rest to the insurance fund.
feeToLiquidator = positionResp.exchangedQuoteAssetAmount.mulD(liquidationFeeRatio).divScalar(2);
Decimal.decimal memory totalBadDebt = positionResp.badDebt;
if (feeToLiquidator.toUint() > remainMargin.toUint()) {
liquidationBadDebt = feeToLiquidator.subD(remainMargin);
totalBadDebt = totalBadDebt.addD(liquidationBadDebt);
} else {
remainMargin = remainMargin.subD(feeToLiquidator);
}
if (totalBadDebt.toUint() > 0) {
realizeBadDebt(quoteAsset, totalBadDebt);
}
if (remainMargin.toUint() > 0) {
feeToInsuranceFund = remainMargin;
}
if (feeToInsuranceFund.toUint() > 0) {
transferToInsuranceFund(quoteAsset, feeToInsuranceFund);
}
The fee to liquidator is transferred to the liquidator.
withdraw(quoteAsset, _msgSender(), feeToLiquidator);
The clearing house enters restriction mode to prevent any attacks.
enterRestrictionMode(_amm);
Funding payment
When the funding rate is positive, traders with long positions pay traders with short position and vice versa. First the clearing house needs to get the premium fraction from the AMM, which will be used later to calculate the AMM funding payment profit.
The current block’s timestamp must be no earlier than the AMM’s next funding time.
require(_blockTimestamp() >= nextFundingTime, "settle funding too early");
The premium is equal to the difference between the TWAP price from the price oracle and the TWAP price from the AMM.
Decimal.decimal memory underlyingPrice = Decimal.decimal(priceFeed.getTwapPrice(priceFeedKey, spotPriceTwapInterval));
SignedDecimal.signedDecimal memory premium = MixedDecimal.fromDecimal(getTwapPrice(spotPriceTwapInterval)).subD(underlyingPrice);
The premium fraction is equal to the premium times the funding period.
SignedDecimal.signedDecimal memory premiumFraction = premium.mulScalar(fundingPeriod).divScalar(int256(1 days));
The funding rate is equal to the premium fraction divided by the TWAP price from the price oracle.
fundingRate = premiumFraction.divD(underlyingPrice);
The next funding time is being updated. The next funding update cannot be earlier than this timestamp.
uint256 minNextValidFundingTime = _blockTimestamp().add(fundingBufferPeriod);
uint256 nextFundingTimeOnHourStart = nextFundingTime.add(fundingPeriod).div(1 hours).mul(1 hours);
// max(nextFundingTimeOnHourStart, minNextValidFundingTime)
nextFundingTime = nextFundingTimeOnHourStart > minNextValidFundingTime
? nextFundingTimeOnHourStart
: minNextValidFundingTime;
The cumulative premium fraction is added to the AMM’s cumulative premium fraction.
ammMap[address(_amm)].cumulativePremiumFractions.push(premiumFraction.addD(getLatestCumulativePremiumFraction(_amm)));
The funding payment is equal to the premium fraction times the AMM’s total position size.
SignedDecimal.signedDecimal memory totalTraderPositionSize = _amm.getBaseAssetDelta();
SignedDecimal.signedDecimal memory ammFundingPaymentProfit = premiumFraction.mulD(totalTraderPositionSize);
If the funding payment is positive, the profit is transferred to the insurance fund. Otherwise, the funding payment is withdrawn from the insurance fund.
IERC20 quoteAsset = _amm.quoteAsset();
if (ammFundingPaymentProfit.toInt() < 0) {
insuranceFund.withdraw(quoteAsset, ammFundingPaymentProfit.abs());
} else {
transferToInsuranceFund(quoteAsset, ammFundingPaymentProfit.abs());
}
If the insurance fund does not have enough balance to cover the funding payment, it calls InsuranceFund#swapEnoughQuoteAmount
to meet the amount required. If the amount is still not fulfilled after swapping all the insurance fund’s quote assets for the target quote asset, the minter will have to mint $PERP to cover the loss. The protocol has an inflation monitor that logs $PERP minting events.
if (_requiredQuoteAmount.toUint() > 0) {
Decimal.decimal memory requiredPerpAmount =
exchange.getOutputPrice(perpToken, _quoteToken, _requiredQuoteAmount);
minter.mintForLoss(requiredPerpAmount);
swapInput(perpToken, _quoteToken, requiredPerpAmount, Decimal.zero());
}
mintedTokenHistory.push(MintedTokenEntry({ timestamp: _blockTimestamp(), cumulativeAmount: cumulativeAmount }));
Closing a position
A trader can close his position by calling ClearingHouse#closePosition
. If the position exceeds the fluctuation limit and the partial liquidation ratio is less than 1, the position is closed partially by calling openReversePosition
. Otherwise, the position is fully closed by calling internalClosePosition
.
if (
_amm.isOverFluctuationLimit(dirOfBase, position.size.abs()) &&
partialLiquidationRatio.cmp(Decimal.one()) < 0
) {
Decimal.decimal memory partiallyClosedPositionNotional =
_amm.getOutputPrice(dirOfBase, position.size.mulD(partialLiquidationRatio).abs());
positionResp = openReversePosition(
_amm,
position.size.toInt() > 0 ? Side.SELL : Side.BUY,
trader,
partiallyClosedPositionNotional,
Decimal.one(),
Decimal.zero(),
true
);
setPosition(_amm, trader, positionResp.position);
} else {
positionResp = internalClosePosition(_amm, trader, _quoteAssetAmountLimit);
}
The clearing house takes care of any bad debt by calling realizeBadDebt (like in other examples).
if (positionResp.badDebt.toUint() > 0) {
enterRestrictionMode(_amm);
realizeBadDebt(quoteToken, positionResp.badDebt);
}
USDC is sent to the trader if and fees are transferred to the insurance fund and the fee pool.
withdraw(quoteToken, trader, positionResp.marginToVault.abs());
Decimal.decimal memory transferredFee = transferFee(trader, _amm, positionResp.exchangedQuoteAssetAmount);
Settling a position
After an AMM is shut down, a trader can settle his position by calling ClearingHouse#settlePosition
.
It first resets the trader’s position.
clearPosition(_amm, trader);
The settlement price is calculated during the AMM shutdown.
Decimal.decimal memory settlementPrice = _amm.getSettlementPrice();
The settlement price’s formula is equal to (in Amm#implShutdown
)
/*
* position size = init base reserve - current base reserve
* open position notional value = init quote reserve - current quote reserve
* settlement price = sum of position notional value ÷ sum of total position size
*/
/* k = x * y */
Decimal.decimal memory previousK = latestLiquiditySnapshot.baseAssetReserve.mulD(latestLiquiditySnapshot.quoteAssetReserve);
/* init base reserve = total position size + base asset reserve */
SignedDecimal.signedDecimal memory lastInitBaseReserveInNewCurve = latestLiquiditySnapshot.totalPositionSize.addD(latestLiquiditySnapshot.baseAssetReserve);
/* init quote reserve = k ÷ init base reserve */
SignedDecimal.signedDecimal memory lastInitQuoteReserveInNewCurve = MixedDecimal.fromDecimal(previousK).divD(lastInitBaseReserveInNewCurve);
/* position notional value = init quote reserve - current quote reserve */
SignedDecimal.signedDecimal memory positionNotionalValue = lastInitQuoteReserveInNewCurve.subD(quoteAssetReserve);
/* settlement price = positional notional value - total position size */
settlementPrice = positionNotionalValue.abs().divD(totalPositionSize.abs());
Assuming the settlement price is nonzero, the amount to return to the trader is equal to his position size times the settlement and open price difference plus his margin.
/* returned fund = position size x (settlement price - open price) + position margin */
SignedDecimal.signedDecimal memory returnedFund = pos.size.mulD(MixedDecimal.fromDecimal(settlementPrice).subD(pos.openNotional.divD(pos.size.abs()))).addD(pos.margin);
The clearing house transfers what is owed to the trader if the returned fund amount is positive.
if (returnedFund.toInt() > 0) {
settledValue = returnedFund.abs();
}
if (settledValue.toUint() > 0) {
_transfer(_amm.quoteAsset(), trader, settledValue);
}
Recent development
You can see the protocol’s volume here.
Key takeaways
The Perpetual Protocol is a cash settlement protocol. No underlying assets are stored and the price is calculated using vAMM.
Trading on the protocol costs next to nothing because it runs on xDai instead of the Ethereum mainnet.
Traders can trade with up to 10x leverage. Failing to meet the maintenance margin ratio will result in liquidation.
Trading fees are distributed between the insurance fund and the fee pool. The insurance fund is used to cover losses when there are bad debts. When the insurance fund does not have enough to cover losses, $PERPs are minted.
Restriction modes kick in to prevent multiple transactions in the same block if hackers attack the protocol.