Skip to main content

VirtualPool

Overview​

VirtualPool is the swap contract of one market. It swaps between crvUSD and the market's asset in one call. This page is for developers who swap through it or route arbitrage through it. Users trade without holding liquidity provider (LP) tokens of the Curve pool. get_dy(i, j, in_amount) gives quotes, and exchange(i, j, in_amount, min_out, _for) executes swaps. In both functions, index 0 is crvUSD and index 1 is the asset.

Each swap uses a flash loan of crvUSD from Factory.flash(). The LEVAMM (contract AMM) is the market's leveraged AMM, which trades crvUSD against Curve pool LP tokens. The swap runs in one of two directions:

  • Stable→asset. The contract borrows crvUSD and combines it with the user's input. It buys LP through the LEVAMM and withdraws the LP back to the Curve pool's coins. Then it repays the flash loan and sends the asset to the recipient.
  • Asset→stable. The contract borrows crvUSD and adds it with the user's asset to mint LP. It sells the LP for crvUSD through the LEVAMM. Then it repays the flash loan and sends the crvUSD.

The contract holds LP only during the call. Users never receive LP.


Pools with a policy or an LP allowlist​

On Twocrypto v3.0.0 pools, two pool features change VirtualPool behavior:

  • A pool policy, a contract that the Curve pool calls to set its swap fee, its price_scale target, or both. For the policy reference, read Pool policy.
  • The LP allowlist. When its flag is on, the pool lets only listed addresses call add_liquidity. For the allowlist rules, read Pool policy and LP allowlist in Factory.

This table gives the effect of each feature for each swap direction:

Swap directionPool callEffect of a policyEffect of the LP allowlist
Stablecoin to asset (i = 0)remove_liquidityThe pool calls the policy with a 300,000 gas limit. If that policy call fails, the pool requires that at least 500,000 gas was left immediately before the policy call, or it reverts.None
Asset to stablecoin (i = 1)add_liquidityThe pool calls the policy. If the policy reverts, the swap reverts.If the allowlist is on and VirtualPool is not listed, the swap reverts with !wl.
Gas for a stablecoin-to-asset swap

Give a stablecoin-to-asset swap enough gas for the policy call. If the policy call fails and less than 500,000 gas was left immediately before it, the swap reverts.

get_dy() does not include the Curve pool fee or a policy fee. In an asset-to-stablecoin swap, the add to the pool pays NOISE_FEE, a fixed pool fee of 0.1 bp. During the donation protection period of the pool, it can also pay a spam penalty of up to half the pool fee. If the pool policy returns a fee for the add, that fee sets the penalty. For the penalty rule, read get_fee in Pool policy. Set min_out from get_dy() minus your tolerance.

To check a market, read pool.POLICY() and pool.lp_allowlist(empty(address)). On markets 0 to 6, POLICY() reverts. These pools have no policy and no allowlist.


Public variables​

VirtualPool has these public variables.

Constants

  • ROUNDING_DISCOUNT (uint256) - Rounding discount applied to stablecoin inputs: 10**18 // 10**8 = 10**10 on a 1e18 base, a fraction of 10^-8 (0.000001%). Example: on get_dy(0, 1, 1_000 * 10**18), the discount reduces the input to 999_999_990_000_000_000_000, that is 999.99999 crvUSD.

Contract addresses

  • FACTORY (Factory) - Factory contract address (immutable)
  • AMM (YbAMM) - LEVAMM contract address (immutable)
  • POOL (Pool) - Curve pool contract address (immutable)
  • ASSET_TOKEN (ERC20) - Asset token address (immutable)
  • STABLECOIN (ERC20) - crvUSD token address (immutable)
  • IMPL (address) - Implementation address (immutable)

View functions

  • coins(i: uint256) (ERC20) - Returns token address for index i (0=stablecoin, 1=asset)

Data structs​

VirtualPool reads this struct from the LEVAMM.

AMMState

  • collateral (uint256) - LP token amount held as collateral
  • debt (uint256) - crvUSD debt with accrued interest
  • x0 (uint256) - Virtual balance for maintaining leverage

Events​

VirtualPool emits this event.

TokenExchange: exchange() emits this event.

  • buyer (indexed address) - Recipient of the output (_for), not the caller
  • sold_id (uint256) - Index of token sold (0=stablecoin, 1=asset)
  • tokens_sold (uint256) - Amount of tokens sold
  • bought_id (uint256) - Index of token bought (0=stablecoin, 1=asset)
  • tokens_bought (uint256) - Amount of tokens bought

Functions​

exchange()​

Description: Swaps between crvUSD and the asset through VirtualPool. The contract uses a flash loan of crvUSD internally, so users never need to hold LP tokens.

Before the call, approve VirtualPool for in_amount of the input coin: crvUSD for i = 0, or the asset for i = 1. VirtualPool pulls in_amount from the caller.

Returns: uint256 - Output amount received after the swap.

Emits: TokenExchange.

The function takes these inputs:

InputTypeDescription
iuint256Input token index (0=stablecoin, 1=asset)
juint256Output token index (must be opposite of i)
in_amountuint256Amount of input tokens to swap
min_outuint256Minimum acceptable output amount (slippage protection)
_foraddressRecipient address for output tokens (defaults to msg.sender)
Contract source: exchange()

The contract source gives the flash-loan interface, the token declarations, and this function body:

interface Flash:
def flashLoan(receiver: address, token: address, amount: uint256, data: Bytes[10**5]) -> bool: nonpayable
def supportedTokens(token: address) -> bool: view
def maxFlashLoan(token: address) -> uint256: view

ASSET_TOKEN: public(immutable(ERC20))
STABLECOIN: public(immutable(ERC20))

@external
@nonreentrant
def exchange(i: uint256, j: uint256, in_amount: uint256, min_out: uint256, _for: address = msg.sender) -> uint256:
assert (i == 0 and j == 1) or (i == 1 and j == 0)
flash: Flash = staticcall FACTORY.flash()

in_coin: ERC20 = [STABLECOIN, ASSET_TOKEN][i]
out_coin: ERC20 = [STABLECOIN, ASSET_TOKEN][j]

assert extcall in_coin.transferFrom(msg.sender, self, in_amount, default_return_value=True)

data: Bytes[128] = empty(Bytes[128])
data = abi_encode(i, in_amount)
extcall flash.flashLoan(self, STABLECOIN.address, staticcall flash.maxFlashLoan(STABLECOIN.address), data)

out_amount: uint256 = staticcall out_coin.balanceOf(self)
assert out_amount >= min_out, "Slippage"
assert extcall out_coin.transfer(_for, out_amount, default_return_value=True)

log TokenExchange(buyer=_for, sold_id=i, tokens_sold=in_amount, bought_id=j, tokens_bought=out_amount)
return out_amount

exchange() can revert with these strings:

StringRaised byCondition
SlippageVirtualPool.exchangeThe output amount is less than min_out.
!wlThe Curve poolIn an asset-to-stablecoin swap, the LP allowlist is on and does not list VirtualPool.

An index pair other than (0, 1) or (1, 0) reverts without a message. The swap also reverts when the LEVAMM swap inside the flash loan fails. For those strings, read exchange() in LEVAMM.


get_dy()​

Description: Quotes the expected output for a given input. It applies the LEVAMM fee. On i=0 (stable → asset), it also applies a small ROUNDING_DISCOUNT to avoid over-estimation. It does not include the Curve pool fee or policy effects, and it assumes an exactly balanced add or remove. Treat the result as an estimate and set min_out.

Returns: uint256 - Expected output amount. in_amount and the result are in raw token units. crvUSD has 18 decimals, and the asset has its own decimals (for example, 8 for cbBTC).

The function takes these inputs:

InputTypeDescription
iuint256Input token index (0=stablecoin, 1=asset)
juint256Output token index (must be opposite of i)
in_amountuint256Amount of input tokens to swap
Contract source: get_dy()

The contract source gives the pool interface, the declarations, this function body, and the internal function that it calls:

interface Pool:
def approve(_spender: address, _value: uint256) -> bool: nonpayable
def coins(i: uint256) -> ERC20: view
def balances(i: uint256) -> uint256: view
def calc_token_amount(amounts: uint256[2], deposit: bool) -> uint256: view
def add_liquidity(amounts: uint256[2], min_mint_amount: uint256) -> uint256: nonpayable
def remove_liquidity(amount: uint256, min_amounts: uint256[2]) -> uint256[2]: nonpayable
def totalSupply() -> uint256: view

FACTORY: public(immutable(Factory))
AMM: public(immutable(YbAMM))
POOL: public(immutable(Pool))
ASSET_TOKEN: public(immutable(ERC20))
STABLECOIN: public(immutable(ERC20))
ROUNDING_DISCOUNT: public(constant(uint256)) = 10**18 // 10**8
IMPL: public(immutable(address))

@external
@view
def get_dy(i: uint256, j: uint256, in_amount: uint256) -> uint256:
assert (i == 0 and j == 1) or (i == 1 and j == 0)
_in_amount: uint256 = in_amount
if i == 0:
_in_amount = in_amount * (10**18 - ROUNDING_DISCOUNT) // 10**18
return self._calculate(i, _in_amount, False)[0]

@internal
@view
def _calculate(i: uint256, in_amount: uint256, only_flash: bool) -> (uint256, uint256):
stables_in_pool: uint256 = staticcall POOL.balances(0)
out_amount: uint256 = 0

if i == 0:
state: AMMState = staticcall AMM.get_state()
pool_supply: uint256 = staticcall POOL.totalSupply()
fee: uint256 = staticcall AMM.fee()
r0fee: uint256 = stables_in_pool * (10**18 - fee) // pool_supply

# Solving quadratic eqn instead of calling the AMM b/c we have a special case
b: uint256 = state.x0 - state.debt + in_amount - r0fee * state.collateral // 10**18
D: uint256 = b**2 + 4 * state.collateral * r0fee // 10**18 * in_amount
flash_amount: uint256 = (isqrt(D) - b) // 2 # We received this withdrawing from the pool

if not only_flash:
crypto_in_pool: uint256 = staticcall POOL.balances(1)
out_amount = flash_amount * crypto_in_pool // stables_in_pool
# Withdrawal was ideally balanced
return out_amount, flash_amount

else:
crypto_in_pool: uint256 = staticcall POOL.balances(1)
flash_amount: uint256 = in_amount * stables_in_pool // crypto_in_pool
if not only_flash:
pool_supply: uint256 = staticcall POOL.totalSupply()
lp_amount: uint256 = pool_supply * in_amount // crypto_in_pool
out_amount = staticcall AMM.get_dy(1, 0, lp_amount) - flash_amount
return out_amount, flash_amount

onFlashLoan()​

Description: Flash-loan callback. It runs the swap: the contract borrows crvUSD with the flash loan, swaps through the LEVAMM and the Curve pool, and then repays the loan. It reverts if the caller is not the configured flash provider or the token is not crvUSD. For a wrong caller, the revert string is Wrong caller.

The function takes these inputs:

InputTypeDescription
initiatoraddressContract that initiated the flash loan (must be self)
tokenaddressToken borrowed (must be crvUSD)
total_flash_amountuint256Amount lent. The fee is a separate value.
feeuint256Flash loan fee amount
dataBytes[10**5]Encoded parameters (i, in_amount)
Contract source: onFlashLoan()

The contract source gives the pool and LEVAMM interfaces, the declarations, and this function body:

interface Pool:
def approve(_spender: address, _value: uint256) -> bool: nonpayable
def coins(i: uint256) -> ERC20: view
def balances(i: uint256) -> uint256: view
def calc_token_amount(amounts: uint256[2], deposit: bool) -> uint256: view
def add_liquidity(amounts: uint256[2], min_mint_amount: uint256) -> uint256: nonpayable
def remove_liquidity(amount: uint256, min_amounts: uint256[2]) -> uint256[2]: nonpayable
def totalSupply() -> uint256: view

interface YbAMM:
def coins(i: uint256) -> ERC20: view
def get_dy(i: uint256, j: uint256, in_amount: uint256) -> uint256: view
def get_state() -> AMMState: view
def fee() -> uint256: view
def exchange(i: uint256, j: uint256, in_amount: uint256, min_out: uint256) -> uint256: nonpayable
def STABLECOIN() -> ERC20: view
def COLLATERAL() -> Pool: view

FACTORY: public(immutable(Factory))
AMM: public(immutable(YbAMM))
POOL: public(immutable(Pool))
ASSET_TOKEN: public(immutable(ERC20))
STABLECOIN: public(immutable(ERC20))
ROUNDING_DISCOUNT: public(constant(uint256)) = 10**18 // 10**8
IMPL: public(immutable(address))

@external
def onFlashLoan(initiator: address, token: address, total_flash_amount: uint256, fee: uint256, data: Bytes[10**5]):
assert initiator == self
assert token == STABLECOIN.address
assert msg.sender == (staticcall FACTORY.flash()).address, "Wrong caller"

i: uint256 = 0
in_amount: uint256 = 0
i, in_amount = abi_decode(data, (uint256, uint256))
flash_amount: uint256 = self._calculate(i, in_amount, True)[1]
in_coin: ERC20 = [STABLECOIN, ASSET_TOKEN][i]
out_coin: ERC20 = [STABLECOIN, ASSET_TOKEN][1-i]
repay_flash_amount: uint256 = total_flash_amount

if i == 0:
# stablecoin -> crypto exchange
# 1. Take flash loan
# 2. Use our stables + flash borrowed amount to swap to pool LP in AMM
# 3. Withdraw symmetrically from pool LP
# 4. Repay the flash loan
# 5. Send the crypto
lp_amount: uint256 = extcall AMM.exchange(0, 1, (in_amount * (10**18 - ROUNDING_DISCOUNT) // 10**18 + flash_amount), 0)
extcall POOL.remove_liquidity(lp_amount, [0, 0])
repay_flash_amount = staticcall STABLECOIN.balanceOf(self)

else:
# crypto -> stablecoin exchange
# 1. Take flash loan
# 2. Deposit taken stables + to Pool
# 3. Swap LP of the pool to stables
# 4. Repay flash loan
# 5. Send the rest to the user
lp_amount: uint256 = extcall POOL.add_liquidity([flash_amount, in_amount], 0)
extcall AMM.exchange(1, 0, lp_amount, 0)

assert extcall STABLECOIN.transfer(msg.sender, repay_flash_amount, default_return_value=True)