Create Publication

We are looking for publications that demonstrate building dApps or smart contracts!
See the full list of Gitcoin bounties that are eligible for rewards.

Solution Thumbnail

Flash Loans on Algorand VM vs Ethereum VM

Overview

In this article, we will be taking a look at Flash Loans! This is a comparison piece between what is currently possible on Algorand vs EVM chains discussing the technologies involved and the pros and cons of each.
Flash loans are a financial instrument that is only possible thanks to atomic transactions on a blockchain. A flash loan consists of a loan that can be taken out without collateral and has to be repaid in the same atomic transaction. The atomicity of a flash loan is what allows it to be secure. The lender is never at risk (provided that the logic is sound) because it is guaranteed to be repaid (plus profits) no matter what happens in between.
We will analyze and compare different flash loan solutions based on how easy, secure, and expressive they are.

If you don’t feel like reading the whole article, skip to the Conclusion chapter.

EditorImages/2023/10/31 11:22/IMG_9636_resize.png

Table of Content - Flash Loans

Algorand and EVM chains

It’s worth reading this article explaining the bulk of the differences before diving into flash loans. If you already feel confident, let’s go!

We will be comparing the typical flow of executing arbitrage leveraging flash loans and we will do so by comparing two abstract imaginary DeFi protocols that closely resemble actual DeFi protocols for each respective chain ecosystem.
For Algorand, the code takes inspiration mostly from Folks Finance. Folks Finance is a popular DeFi platform that offers Lend&Borrow, Swaps, Cross-chain Governance, Trade Router, and of course Flash Loans.
For EVM chains, mostly from AAVE.

Algorand

Flash loans on Algorand typically take the form of transaction groups. Transaction groups are a way to bundle together standalone transactions such that they have to happen all or none. This has many applications, from swaps to bundling asset transfers with application calls to, of course, flash loans. This means that, depending on the logic of the lender contract, it could be as simple as having a transaction group of [Opening loan, Arbitrage, Repaying transaction, Closing loan].

The arbitrage can be implemented by one or many transactions and can be the same kind of transaction that you would execute outside the context of a flash loan. The only difference in doing your transaction within the context of a flash loan is that you’ll temporarily be able to leverage your borrowed assets. You’ll still compile, sign, and execute your trade the same way as if you didn’t use a flash loan.

This way of building an atomic group is also composable with external SDKs that inject/return transactions for all the protocols involved in the trade. Take for example this function from Folks Finance.

function wrapWithFlashLoan(
  txns: Transaction[],
  pool: Pool,
  userAddr: string,
  receiverAddr: string,
  reserveAddr: ReserveAddress,
  borrowAmount: number | bigint,
  params: SuggestedParams,
  flashLoanFee: bigint = BigInt(0.001e16),
): Transaction[] {
  // clear group id in passed txns
  const wrappedTxns = txns.map(txn => {
    txn.group = undefined;
    return txn;
  });

  // add flash loan begin
  const txnIndexForFlashLoanEnd = txns.length + 2;
  const flashLoanBegin = prepareFlashLoanBegin(pool, userAddr, receiverAddr, borrowAmount, txnIndexForFlashLoanEnd, params);
  wrappedTxns.unshift(flashLoanBegin);

  // add flash loan end
  const repaymentAmount = calcFlashLoanRepayment(BigInt(borrowAmount), flashLoanFee);
  const flashLoanEnd = prepareFlashLoanEnd(pool, userAddr, reserveAddr, repaymentAmount, params);
  wrappedTxns.push(...flashLoanEnd);

  // return txns wrapped with flash loan
  return wrappedTxns;
}

It’s a wrapper around your trade and is essentially agnostic to how the trade itself is implemented.
If your arbitrage is so complicated and sensible to on-chain parameters, you always have the option to implement your arbitrage as a smart contract and let it do what you need through inner transactions. This is optional though and it’s a point that we will come back to later.

EVM chains

Conversely, a flash loan on an EVM chain typically is in the form of a contract-to-contract callback. It is necessary to write a contract that implements your trade which will call the lender, will execute the trade and lastly will repay the lender (or let it pull the balance back). What is more, every lender defines and expects a different contract interface to lend/be repaid/reclaim funds from a contract. Let’s take a look at AAVE’s

interface IFlashLoanReceiver {

...

  function executeOperation(
    address[] calldata assets,
    uint256[] calldata amounts,
    uint256[] calldata premiums,
    address initiator,
    bytes calldata params
  ) external returns (bool);

  function ADDRESSES_PROVIDER() external view returns (IPoolAddressesProvider);

  function POOL() external view returns (IPool);
}

and Balancer’s.

interface IFlashLoanRecipient {

...

    function receiveFlashLoan(
        IERC20[] memory tokens,
        uint256[] memory amounts,
        uint256[] memory feeAmounts,
        bytes memory userData
    ) external;
}

This means that every trade using a flash loan is tailor-made for the contracts that it interacts with. We expect the calls to each protocol to be different but in this case the implementation of the trade itself needs to be custom each time.

Smart contracts

These smart contracts contain deliberate errors to prevent them from actually compiling. These contracts are not supposed to serve as guidelines for implementing a DeFi protocol and are shown for the only purpose of comparing the language and the underlying technology. Please do not use these contracts as a reference implementation.

Algorand - Lender

You can find the full abstract Flash Loan protocol at this link. For the moment, let’s note that your Algorand contract should correctly match the (or each) open flash loan call to a close flash loan call. This can be easy to get wrong especially if you decide to allow full composition and/or multiple different kinds of calls to your smart contract.

While it is possible to do so, in this implementation we decided to have at most one flash loan per group. The checks in this contract will make sure that an open flash loan call corresponds to a close flash loan call. The converse is also true so both imply that they have to both be present in the group or none is.
The checks also make sure that the open call is the first and that the close is last. This implies that at most one flash loan per group is present (it would be impossible to have more than one open because they would have to be in the same position and, therefore, be the same).

openFlashLoan(amount: UnsignedInteger): void {
 assert(this.txn.GroupIndex === 0);
 assert(this.txnGroup[this.txnGroup.length - 1].typeEnum === TransactionType.ApplicationCall);
 assert(this.txnGroup[this.txnGroup.length - 1].applicationID === globals.currentApplicationID);
 assert(this.txnGroup[this.txnGroup.length - 1].applicationArgs[0] === method('closeFlashLoan(pay)void'));
 assert(this.txn.GroupIndex === 1);

 sendPayment({
   recipient: this.txn.Sender,
   amount: amount,
   fee: 0,
 });
}

closeFlashLoan(repay: PaymentTransaction): void {
 assert(this.txn.GroupIndex === this.txnGroup.length - 1);
 assert(this.txnGroup[0].typeEnum === TransactionType.ApplicationCall);
 assert(this.txnGroup[0].applicationID === globals.currentApplicationID);
 assert(this.txnGroup[0].applicationArgs[0] === method('openFlashLoan(uint64)void'));

 assert(repay.recipient === this.app.address);
 assert(repay.amoutn === btoi(this.txnGroup[0].applicationArgs[1]));
}

Addendum

We mentioned that it is possible to have fully composable flash loans (meaning multiple flash loan calls to the same/some other protocol). Folks Finance, for instance, implements flash loans with full composability. Check out this example.

EVM chain - Lender

As mentioned above, the lender is a contract calling back the borrower contract. Without focusing too much on the details of an actual DeFi protocol, the code is going to be sending funds to the contract, calling a callback, and pulling funds from the ERC20 token.

...

function executeFlashLoan(
 mapping(address => DataTypes.ReserveData) storage reservesData,
 mapping(uint256 => address) storage reservesList,
 mapping(uint8 => DataTypes.EModeCategory) storage eModeCategories,
 DataTypes.UserConfigurationMap storage userConfig,
 DataTypes.FlashloanParams memory params
) external {
 ValidationLogic.validateFlashloan(reservesData, params.assets, params.amounts);

...

 require(
   vars.receiver.executeOperation(
     params.assets,
     params.amounts,
     vars.totalPremiums,
     msg.sender,
     params.params
   ),
   Errors.INVALID_FLASHLOAN_EXECUTOR_RETURN
 );

 _handleFlashLoanRepayment(
   reservesData[vars.currentAsset],
   DataTypes.FlashLoanRepaymentParams({
     asset: vars.currentAsset,
     receiverAddress: params.receiverAddress,
     amount: vars.currentAmount,
     totalPremium: vars.totalPremiums[vars.i],
     flashLoanPremiumToProtocol: vars.flashloanPremiumToProtocol,
     referralCode: params.referralCode
   })
 );
}

...

Algorand - Borrower

No contract code is necessary for the borrower.

EVM chain - Borrower

This contract implements both the trade logic and the interface for AAVE’s callback. This is what will be called first and in turn, call AAVE to start the flash loan. AAVE will then call back the same contract on executeOperation.

contract Flashloan is FlashLoanReceiverBase {
    ...

    function executeOperation(
        address _reserve,
        uint256 _amount,
        uint256 _fee,
        bytes calldata _params
    )
        external
        override
    {
        require(_amount <= getBalanceInternal(address(this), _reserve), "Invalid balance, was the flashLoan successful?");

        //
        // Your logic goes here.
        // !! Ensure that *this contract* has enough of `_reserve` funds to payback the `_fee` !!
        //
        dex1.sellBTC({amount: 10, price: 24_900});
        dex2.buyBTC({amount: 10, price: 24_890});

        uint totalDebt = _amount.add(_fee);
        transferFundsBackToPoolInternal(_reserve, totalDebt);
    }

    function flashloan() public onlyOwner {
        /**
        * Flash Loan of 1000 DAI
        */
        address receiver = address(this) // Can also be a separate contract
        address asset = "0x6b175474e89094c44da98b954eedeac495271d0f"; // Dai
        uint256 amount = 1000 * 1e18;

        // If no params are needed, use an empty params:
        bytes memory params = "";
        // Else encode the params like below (bytes encoded param of type `address` and `uint`)
        // bytes memory params = abi.encode(address(this), 1234);

        ILendingPool lendingPool = ILendingPool(addressesProvider.getLendingPool());
        lendingPool.flashLoan(address(this), asset, amount, params);
    }
}

Security risks

When implementing flash loans from the lender side, great care should be taken to make sure that loans are opened and closed in the right order and for the right amounts. Especially when considering Algorand’s full composability, you can imagine that it can be quite complicated for groups involving more than one flash loan from, potentially, different DeFi protocols.

Also, the transaction group is always built by the user and therefore should be considered potentially malicious from the contract perspective. It is crucial to not assume in your contract code that the group respects a correct structure. Your code could easily be tricked into opening two loans and only repaying one, for example.

From the borrower side, there is the risk of assuming that whatever advantageous trade you identified can be made more profitable with more money. Flash loans give you more leverage but also, with money that is whale-like, you could easily overwhelm low liquidity markets, there is also slippage risk.

This is not advice and this article is not intended to encourage or discourage the use of flash loans.

Operations and infrastructure

Transactions on EVM chains have some major problems when compared to transactions on Algorand. On EVM, you have to serialize the transactions coming from the same address (because of the nonce). While this is sometimes desirable, it certainly isn’t the majority of the time. With more steps comes more chances of failure in the process. What if, by the time your contract is deployed on the chain, your arbitrage opportunity was already executed by someone else?

Paying for failed transactions also does not help. Since a flash loan on EVM is composed of at least two separate non-atomic transactions, it’s important to have a resilient contract that can adapt to on-chain conditions. This adds to the effort of writing a correct and safe contract that is also complex enough to be useful in many scenarios.

Algorand

Your trade is implemented by using standard Layer-1 transactions, potentially prepared by the SDK of the protocol you intend to use. Also, we use Algokit-like syntax for interacting with the contract. This example uses two abstract exchange protocols that both offer an SDK. The trade is taking advantage of a difference in BTC pricing for those two exchanges:

trade = [dex1sdk.sellBTC({amount: 10, price: 24_900}), dex2sdk.buyBTC({amount: 10, price: 24_890})]

# We interact with the network only once. We still have an atomic trade wrapped by a flash loan.
await appClient
  .compose()
  .openFlashLoan({amount: 10})
  .addTransactions(trade)
  .closeFlashLoan()
  .execute()

This is not working code. It’s just an abstraction over the concept that we covered. The goal of this code snippet is to convey that a flash loan trade is atomic with no in-between steps.

EVM chain

contract = borrower_lib.compile()
contract_address = contract.deploy()

# A lot could happen at this stage in the code. Network congestion changes, trade opportunity is gone or simply a machine/network error stalling the computation.
# Potentially leaving us with a deployed contract that was paid for and can't be used.

contract_address.run()

Conclusion

In this article we’ve seen how crucial it is to implement your smart contracts correctly, have all your actions succeed or fail at the same time, and have little friction when it comes to accessing the network.
Algorand shows that, while keeping code complexity and security in their smart contract comparable to the one of other networks, it offers full atomicity and enhanced expressiveness while still being more accessible.

We’ve covered both the user and the protocol perspectives in this article but there will be many more users than protocols. Therefore, let’s focus for a moment on what Layer-1 primitives are necessary for the user to use a flash loan.
On Algorand, you need Layer-1 transactions and atomic groups. On EVM chains, Layer-1 transactions and contract code.

Some EVM chains enjoy high liquidity and better market efficiency and these qualities are only enjoyed by people with high technical expertise and high initial capital to pay for engineering and fees. These drawbacks stem from the technical challenges and inherent risks with non-atomic flash loans and expensive networks. One common argument for flash loans is that whales do not need flash loans to make arbitrage and trade. Flash loans therefore open up this possibility for many more users without the initial capital. While this is true, in Algorand it is even truer.

Layer-1 capabilities such as atomic groups, absence of transaction nonce, and absence of commission on failed transactions make it safer and easier to use flash loans for more people.