Curve StableSwap Liquidity Pool — Part IV: Arbitrage Helper
Curve Math is Hard, But We Have Helpers
The CurveStableswapPool
class is coming along nicely. Part III compared the results of get_dy
and get_dy_underlying
to their exchange
and exchange_underlying
counterparts. It found some slight differences in amounts for the GUSD-3Crv pool, but nothing that should stop us from continuing to build.
Now let’s move outward and consider ways that a Curve pool could fit into an arbitrage strategy and review the requirements to execute that strategy on-chain.
Curve Arbitrage Strategies
Backrun arbitrage strategies using Uniswap pools generally consist of passing WETH through two or more pools at favorable prices.
The most common is the 2-pool strategy, which can occur within the same DEX (Uniswap V2 ←→ Uniswap V3) or cross-DEX (Sushiswap ←→ Uniswap).
This generally looks like WETH-X → WETH-X, where X is some arbitrary token held in a WETH pair pool.
There are also opportunities for 3-pool arbitrage, whereby an intermediate pool is used to swap between non-WETH tokens.
This generally looks like WETH-X → X-Y → WETH-Y.
These arbitrage paths are frequently not profitable because they require a large dislocation of the X-Y pair to offset the increased gas cost and fee for the extra swap.
Uniswap V2 pools typically implement a 0.3% fee. Uniswap V3 pools implement differing fees depending on the pair type, and frequently the X-Y pools are set up with 1% fees which further compound the problem.
However, Curve Stableswap pools (referred hereafter as Curve V1 for simplicity) have low fees. The swap rates are also more tightly controlled by the Stableswap invariant. Thus, trading through a Curve V1 pool offers advantages:
Curve V1 pool fees are typically lower than Uniswap
Curve V1 pool slippage is lower than Uniswap for correlated pairs, even with lower liquidity
Curve V1 metapool+basepool ecosystem offers a larger selection of X-Y style pools
So if you are interested in doing 3-pool arbitrage, Curve V1 pools are a good choice for the middle leg of the trade in lieu of the equivalent Uniswap pool.
Curve V1 Considerations
Both Uniswap V2 and V3 expect the input token to be transferred to it externally before it does the balance check. This “push” pattern can be more efficient for multiple swaps.
Curve V1 operates on a “pull” pattern, which means it calls transferFrom
on the ERC-20 token address to transfer the input amount from msg.sender
to itself.
The relevant code can be reviewed in Curve’s base template HERE. The trimmed example below shows the transferFrom
/ transfer
execution step where the input is pulled and the output is pushed.
Note that the template below assumes that msg.sender
will both provide the input and receive the output.
@external
@nonreentrant('lock')
def exchange(i: int128, j: int128, _dx: uint256, _min_dy: uint256) -> uint256:
"""
@notice Perform an exchange between two coins
@dev Index values can be found via the `coins` public getter method
@param i Index value for the coin to send
@param j Index valie of the coin to recieve
@param _dx Amount of `i` being exchanged
@param _min_dy Minimum amount of `j` to receive
@return Actual amount of `j` received
"""
[...]
_response: Bytes[32] = raw_call(
self.coins[i],
concat(
method_id("transferFrom(address,address,uint256)"),
convert(msg.sender, bytes32),
convert(self, bytes32),
convert(_dx, bytes32),
),
max_outsize=32,
)
if len(_response) > 0:
assert convert(_response, bool)
_response = raw_call(
self.coins[j],
concat(
method_id("transfer(address,uint256)"),
convert(msg.sender, bytes32),
convert(dy, bytes32),
),
max_outsize=32,
)
if len(_response) > 0:
assert convert(_response, bool)
log TokenExchange(msg.sender, i, _dx, j, dy)
return dy
transferFrom
requires an allowance to be set by owner
(the token holder) to grant spender
(some external address) permission to move that amount. If transferFrom
is called without the allowance already set, the call will revert and the swap will not be performed.
So we need to be careful and consider the allowance at all times.
Minimum Output
The exchange
and exchange_underlying
functions expect a value for the minimum expected output. The helper currently sets this to the expected output of the swap, but this may not be ideal.
If your contract implements a balance check after all swaps are performed, you can guard against reverts caused by slight slippage by setting the amount out to zero. This may be a smart idea in general, especially in light of the very small calculation differences I found in Part III doing underlying swaps via GUSD-3Crv.
Allowance Strategies
Depending on your contract design, you may choose different allowance strategies. Using 3Crv as an example, you have access to any X-Y pair in the set of DAI, USDC, and USDT. Whenever you run an arbitrage through a Curve V1 pool, it will execute the transferFrom
call which requires an allowance to be set that approves the pool to transfer DAI, USDC, or USDT.
If you intend to hold WETH and use the Curve pool as a middleman, setting an unlimited approval for the Curve pool is a generally safe choice.
If you intend to hold DAI, USDC, or USDT as your contract reserve, you might consider a more conservative strategy that sets limited approvals on a per-transaction basis.
Transaction Ordering
The helper takes a naive approach that executes all the transactions in chronological order:
If first pool is Uniswap V2, transfer WETH to pool (transfer in callback if V3)
Call WETH → X at Uniswap, output to self
Approve X for Curve V1 pool if needed
Swap X → Y at Curve
If third pool is Uniswap V2, transfer Y to pool (transfer in callback if V3)
Call Y → WETH swap at Uniswap, output to self
There is likely room for improvement here, which I will study and incorporate after the helper is functional.
Both Uniswap V3 and Curve V1 return a uint256
value for the actual amount received, which indicates that some dynamic sizing could be done by the arbitrage contract.
Arbitrage Helper Construction
I built the Uniswap-only UniswapLpCycle
arbitrage helper over a series of lessons which you can review below in Part I, Part II and Part III of the Cycle Arbitrage Helper series:
I have built the initial version of a UniswapCurveCycle
helper that implements the Uniswap → Curve V1 → Uniswap strategy outlined above.
It’s rough and built to function only for 3-pool paths using Curve V1 as the second leg. I will add input validation and expand the functionality after doing more thorough testing.