Degen Code

Degen Code

Share this post

Degen Code
Degen Code
Curve StableSwap Liquidity Pool — Part IV: Arbitrage Helper

Curve StableSwap Liquidity Pool — Part IV: Arbitrage Helper

Curve Math is Hard, But We Have Helpers

Dec 30, 2023
∙ Paid
3

Share this post

Degen Code
Degen Code
Curve StableSwap Liquidity Pool — Part IV: Arbitrage Helper
1
Share

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.

Curve StableSwap Liquidity Pool — Part III: Contract Swaps

Curve StableSwap Liquidity Pool — Part III: Contract Swaps

BowTiedDevil
·
December 19, 2023
Read full story

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:

UniswapV3 — Cycle Arbitrage Helper (Part I)

UniswapV3 — Cycle Arbitrage Helper (Part I)

BowTiedDevil
·
December 6, 2022
Read full story
UniswapV3 — Cycle Arbitrage Helper (Part II)

UniswapV3 — Cycle Arbitrage Helper (Part II)

BowTiedDevil
·
December 8, 2022
Read full story
UniswapV3 — Arbitrage Helper Payload Generator

UniswapV3 — Arbitrage Helper Payload Generator

BowTiedDevil
·
December 23, 2022
Read full story

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.

This post is for paid subscribers

Already a paid subscriber? Sign in
© 2025 BowTiedDevil
Privacy ∙ Terms ∙ Collection notice
Start writingGet the app
Substack is the home for great culture

Share