VirtualPool
Source: VirtualPool.vy on GitHub
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_scaletarget, 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 direction | Pool call | Effect of a policy | Effect of the LP allowlist |
|---|---|---|---|
Stablecoin to asset (i = 0) | remove_liquidity | The 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_liquidity | The 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. |
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**10on a 1e18 base, a fraction of 10^-8 (0.000001%). Example: onget_dy(0, 1, 1_000 * 10**18), the discount reduces the input to999_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 collateraldebt(uint256) - crvUSD debt with accrued interestx0(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 callersold_id(uint256) - Index of token sold (0=stablecoin, 1=asset)tokens_sold(uint256) - Amount of tokens soldbought_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:
| Input | Type | Description |
|---|---|---|
i | uint256 | Input token index (0=stablecoin, 1=asset) |
j | uint256 | Output token index (must be opposite of i) |
in_amount | uint256 | Amount of input tokens to swap |
min_out | uint256 | Minimum acceptable output amount (slippage protection) |
_for | address | Recipient 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:
| String | Raised by | Condition |
|---|---|---|
Slippage | VirtualPool.exchange | The output amount is less than min_out. |
!wl | The Curve pool | In 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:
| Input | Type | Description |
|---|---|---|
i | uint256 | Input token index (0=stablecoin, 1=asset) |
j | uint256 | Output token index (must be opposite of i) |
in_amount | uint256 | Amount 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:
| Input | Type | Description |
|---|---|---|
initiator | address | Contract that initiated the flash loan (must be self) |
token | address | Token borrowed (must be crvUSD) |
total_flash_amount | uint256 | Amount lent. The fee is a separate value. |
fee | uint256 | Flash loan fee amount |
data | Bytes[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)
Related
- LEVAMM: the leveraged AMM that
exchangeandonFlashLoanroute through. - Factory: the contract that provides the
flashlender. - Rebalance flow: the full arbitrage call sequence.
- Arbitrage: how to run an arbitrage trade through VirtualPool.
- Execute a VirtualPool swap in Quote and route: a web3.py example that quotes and sends a swap in both directions.