DEV Community

Ahmed Castro for Filosofía Código EN

Posted on

One-click Loans on Aave with EIP-7702

Historically, Ethereum has had a high barrier to entry with interfaces that are difficult to understand and use. In May 2025 Ethereum released EIP-7702, which is in my opinion, is the most significant improvement for user experience released on Ethereum. Thanks to it, an EOA, that is a regular Ethereum account, can temporarily become a smart contract.

Aave with EIP-7702
Historically, taking out a loan on Aave takes three transactions. In this article we’ll see how to do it in a single transaction using EIP‑7702.

In this article we’ll show how to use EIP‑7702 to get a loan on Aave with one click. Aave is the largest collateralized lending platform on Ethereum, handling over $50 billion. Before this upgrade, borrowing required at least three transactions, whereas with EIP‑7702 we bundle those three actions into one.

Let’s start by understanding how Aave works, and then we’ll build step by step a frontend that does the entire process with a single button.

How to Get a Loan on Aave

Let's suppose you want $300 in WBTC by posting $1000 in DAI as collateral. To do this, you first have to approve the Aave contract to transfer your DAI via the approve function, then supply your DAI as collateral by calling supply, and finally borrow by calling borrow. From the user’s point of view this isn’t obvious, and until now we had no way to express it in a single transaction.

Position in Aave
Example of an Aave position with a $300 WBTC loan backed by $1000 in DAI collateral.

In our example we’ll go through step by step how to do the same process with a single button. We’ll see how to enable EIP‑7702 in your wallet so that it can aggregate transactions, and then we’ll build a frontend to interact with Aave from your smart-contract wallet.

Temporarily Turn Your Wallet into a Smart Contract

With EIP‑7702 you can inject arbitrary code into your wallet. Another option, to save gas, is to have your wallet point to any other contract so that it “copies” the code at that address, we call this wallet delegation. In this example we’ll deploy and delegate to a Multicall7702 contract based on Multicall, a contract that batches multiple transactions but I adapted it for EIP‑7702 so it only allows the deployer to execute it. Otherwise your wallet would be exposed.

Wallet using EIP-7702
First, we’ll deploy the Multicall7702 contract and then point our wallet at that contract.

Wallet interfaces don’t yet support EIP‑7702, so for now we’ll use Foundry instead. Also, as dApp developers we shouldn’t worry about handling delegation in our interfaces. For security reasons, this should be implemented at the wallet level, not the dApp level.

Deploy the Multicall7702 Contract

Deploy the following contract on the network where you want to try this example and save the address. I’ll deploy it on Scroll Testnet, where you can connect via Chainlist and get funds from the faucet by messaging @scroll_up_sepolia_bot and then typing /drop 0xYOUR_WALLET_ADDRESS in the faucet chat.

If you deploy on another chain, I’ll explain the changes you need to make along the way.

The only change you must make before deploying the Multicall7702 contract is to replace 0xYOUR_EOA_ADDRESS with the wallet you want to convert:

// SPDX-License-Identifier: MIT
// Derived from MakerDAO’s Multicall3
// NOT AUDITED, DO NOT USE IN PRODUCTION
pragma solidity 0.8.12;

contract Multicall7702 {
    struct Call3Value { address target; bool allowFailure; uint256 value; bytes callData; }
    struct Result { bool success; bytes returnData; }

    address immutable public owner = 0xTU_DIRECCION_EOA;

    function aggregate3Value(Call3Value[] calldata calls) public payable returns (Result[] memory returnData) {
        require(owner == msg.sender, "Only owner"); // Prevent others from executing on your behalf
        uint256 valAccumulator;
        uint256 length = calls.length;
        returnData = new Result[](length);
        Call3Value calldata calli;
        for (uint256 i = 0; i < length;) {
            Result memory result = returnData[i];
            calli = calls[i];
            uint256 val = calli.value;
            unchecked { valAccumulator += val; }
            (result.success, result.returnData) = calli.target.call{value: val}(calli.callData);
            assembly {
                if iszero(or(calldataload(add(calli, 0x20)), mload(result))) {
                    mstore(0x00, 0x08c379a000000000000000000000000000000000000000000000000000000000)
                    mstore(0x04, 0x0000000000000000000000000000000000000000000000000000000000000020)
                    mstore(0x24, 0x0000000000000000000000000000000000000000000000000000000000000017)
                    mstore(0x44, 0x4d756c746963616c6c333a2063616c6c206661696c6564000000000000000000)
                    revert(0x00, 0x84)
                }
            }
            unchecked { ++i; }
        }
        require(msg.value == valAccumulator, "Multicall3: valor incorrecto");
    }
}
Enter fullscreen mode Exit fullscreen mode

Delegate Your Wallet to Multicall7702

Install Foundry if you don’t have it:

curl -L https://foundry.paradigm.xyz | bash
foundryup
Enter fullscreen mode Exit fullscreen mode

To delegate your wallet, run the following command—replacing YOUR_PRIVATE_KEY and 0xYOUR_CONTRACT_ADDRESS. If you’re on another chain, change the --rpc-url (you can get it from Chainlist):

cast send \
  --rpc-url https://scroll-public.scroll-testnet.quiknode.pro \
  --private-key YOUR_PRIVATE_KEY \
  --auth 0xYOUR_CONTRACT_ADDRESS $(cast az)
Enter fullscreen mode Exit fullscreen mode

You can verify the delegation with:

cast code \
  --rpc-url https://scroll-public.scroll-testnet.quiknode.pro \
  0xYOUR_EOA_ADDRESS
Enter fullscreen mode Exit fullscreen mode

You should see your Multicall7702 address in the form:

0xef0100YOUR_EOA_ADDRESS
Enter fullscreen mode Exit fullscreen mode

Here 0xef0100 is the EIP‑7702 delegation prefix plus your contract address, meaning your wallet points to your Multicall7702 code.

Build the Frontend

Next, we’ll create a minimal frontend demo of EIP‑7702. All the code is commented so you can understand what’s needed for compatibility with this new standard.

In our dApp, the user will select how much WBTC to borrow backed by DAI. If you want to use other tokens, I’ll note the necessary changes.

EIP-7702 dApp
We’ll build a very simple EIP‑7702–enabled dApp implementation.

We’ll use two files: index.html for the UI and blockchain_stuff.js for the logic. Place them side by side in a new folder.

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
</head>
<body>
  <input id="connect_button" type="button" value="Connect" onclick="connectWallet()" style="display: block"></input>
  <p id="account_address" style="display: none"></p>
  <p id="web3_message"></p>
  <p id="contract_state"></p>
  <label for="lending_token_amount">DAI supply amount:</label>
  <input type="input" value="" id="lending_token_amount" placeholder="e.g. 1000"></input>
  <br>
  <label for="borrowed_token_amount">WBTC borrow amount:</label>
  <input type="input" value="" id="borrowed_token_amount" placeholder="e.g. 0.005"></input>
  <br>
  <input type="button" value="One click loan" onclick="_oneClickLoan()"></input>
  <br>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/web3/1.3.5/web3.min.js"></script>
  <script src="blockchain_stuff.js"></script>
</body>
</html>

<script>
  function _oneClickLoan() {
    const lendingTokenAmount = document.getElementById("lending_token_amount").value;
    const borrowedTokenAmount = document.getElementById("borrowed_token_amount").value;
    oneClickLoan(lendingTokenAmount, borrowedTokenAmount);
  }
</script>
Enter fullscreen mode Exit fullscreen mode

In the following file adjust NETWORK_ID, LENDING_ADDRESS, BORROWED_TOKEN, and AAVE_POOL_ADDRESS for your target chain and tokens.

blockchain_stuff.js

// Edit this if you want to change in other chain
const NETWORK_ID = 534351 // Scroll Sepolia

// Edit if you want to use other assets or other chain
const LENDING_ADDRESS = "0x7984e363c38b590bb4ca35aed5133ef2c6619c40" //Aave Testnet DAI en Scroll Sepolia
const BORROWED_TOKEN = "0x5ea79f3190ff37418d42f9b2618688494dbd9693" //Aave Testnet WBTC en Scroll Sepolia
const AAVE_POOL_ADDRESS = "0x48914C788295b5db23aF2b5F0B3BE775C4eA9440" // Aave Testnet Pool en Scroll Sepolia

// ABI we'll use, you should keep this in separate .json files to keep your code clean
const MULTICALL_ABI = [
  {
    "inputs": [
      {
        "components": [
          {"internalType": "address", "name": "target", "type": "address"},
          {"internalType": "bool", "name": "allowFailure", "type": "bool"},
          {"internalType": "uint256", "name": "value", "type": "uint256"},
          {"internalType": "bytes", "name": "callData", "type": "bytes"}
        ],
        "internalType": "struct Multicall3.Call3Value[]",
        "name": "calls",
        "type": "tuple[]"
      }
    ],
    "name": "aggregate3Value",
    "outputs": [
      {
        "components": [
          {"internalType": "bool", "name": "success", "type": "bool"},
          {"internalType": "bytes", "name": "returnData", "type": "bytes"}
        ],
        "internalType": "struct Multicall3.Result[]",
        "name": "returnData",
        "type": "tuple[]"
      }
    ],
    "stateMutability": "payable",
    "type": "function"
  }
];

const ERC20_ABI = [
  {
    "inputs": [
      {"internalType": "address", "name": "spender", "type": "address"},
      {"internalType": "uint256", "name": "value", "type": "uint256"}
    ],
    "name": "approve",
    "outputs": [
      {"internalType": "bool", "name": "", "type": "bool"}
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "decimals",
    "outputs": [
      {"internalType": "uint8", "name": "", "type": "uint8"}
    ],
    "stateMutability": "view",
    "type": "function"
  }
];

const AAVE_POOL_ABI = [
  {
    "inputs": [
      {"internalType": "address", "name": "asset", "type": "address"},
      {"internalType": "uint256", "name": "amount", "type": "uint256"},
      {"internalType": "address", "name": "onBehalfOf", "type": "address"},
      {"internalType": "uint16", "name": "referralCode", "type": "uint16"}
    ],
    "name": "supply",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {"internalType": "address", "name": "asset", "type": "address"},
      {"internalType": "uint256", "name": "amount", "type": "uint256"},
      {"internalType": "uint256", "name": "interestRateMode", "type": "uint256"},
      {"internalType": "uint16", "name": "referralCode", "type": "uint16"},
      {"internalType": "address", "name": "onBehalfOf", "type": "address"}
    ],
    "name": "borrow",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  }
];

// Variables we will use
var lendingTokenContract
var borrowedTokenContract
var aavePoolContract
var myERC7702Account

var accounts
var web3

// Automatically reload when the user selects a new chain or uses another account
function metamaskReloadCallback() {
  window.ethereum.on('accountsChanged', (accounts) => {
    document.getElementById("web3_message").textContent="Se cambió el account, refrescando...";
    window.location.reload()
  })
  window.ethereum.on('networkChanged', (accounts) => {
    document.getElementById("web3_message").textContent="Se el network, refrescando...";
    window.location.reload()
  })
}

// Function to initalize Web3
const getWeb3 = async () => {
  return new Promise((resolve, reject) => {

    if(document.readyState=="complete")
    {
      if (window.ethereum) {
        const web3 = new Web3(window.ethereum)
        window.location.reload()
        resolve(web3)
      } else {
        reject("must install MetaMask")
        document.getElementById("web3_message").textContent="Error: Porfavor conéctate a Metamask";
      }
    }else
    {
      window.addEventListener("load", async () => {
        if (window.ethereum) {
          const web3 = new Web3(window.ethereum)
          resolve(web3)
        } else {
          reject("must install MetaMask")
          document.getElementById("web3_message").textContent="Error: Please install Metamask";
        }
      });
    }
  });
};

// Function to initalize the dApp, read the contracts and connects the wallet
async function loadDapp() {
  metamaskReloadCallback()
  document.getElementById("web3_message").textContent="Please connect to Metamask"
  var awaitWeb3 = async function () {
    web3 = await getWeb3()
    web3.eth.net.getId((err, netId) => {
      if (netId == NETWORK_ID) {
        var awaitContract = async function () {
          lendingTokenContract = new web3.eth.Contract(ERC20_ABI, LENDING_ADDRESS);
          borrowedTokenContract = new web3.eth.Contract(ERC20_ABI, BORROWED_TOKEN);
          aavePoolContract = new web3.eth.Contract(AAVE_POOL_ABI, AAVE_POOL_ADDRESS);

          document.getElementById("web3_message").textContent="You are connected to Metamask"
          onContractsInitCallback()
          web3.eth.getAccounts(function(err, _accounts){
            accounts = _accounts
            if (err != null)
            {
              console.error("An error occurred: "+err)
            } else if (accounts.length > 0)
            {
              onWalletConnectedCallback()
              document.getElementById("account_address").style.display = "block"
            } else
            {
              document.getElementById("connect_button").style.display = "block"
            }
          });
        };
        awaitContract();
      } else {
        document.getElementById("web3_message").textContent="Please connect to Goerli";
      }
    });
  };
  awaitWeb3();
}

async function connectWallet() {
  await window.ethereum.request({ method: "eth_requestAccounts" })
  accounts = await web3.eth.getAccounts()
  onWalletConnectedCallback()
}

loadDapp()

// Wallet executed when you change the accounts, this is a good place to read the on-chain state
const onContractsInitCallback = async () => {
}

// Callback called when the user connects the wallet, in this case we'll initalize smart account wallet
const onWalletConnectedCallback = async () => {
  myERC7702Account = new web3.eth.Contract(MULTICALL_ABI, accounts[0]);
}

// Function that is called from the frontend that receives the desired amount of collateral and borrow amounts
const oneClickLoan = async (lendingTokenAmount, borrowedTokenAmount) => {
    // Convert from ether to wei format depending on the amount of decimals of each token
    const scrDecimals = await lendingTokenContract.methods.decimals().call();
    const borrowedDecimals = await borrowedTokenContract.methods.decimals().call();
    const supplyAmount = Web3.utils.toBN(
        (parseFloat(lendingTokenAmount) * Math.pow(10, parseInt(scrDecimals))).toLocaleString('fullwide', {useGrouping:false})
    );
    const borrowAmount = Web3.utils.toBN(
        (parseFloat(borrowedTokenAmount) * Math.pow(10, parseInt(borrowedDecimals))).toLocaleString('fullwide', {useGrouping:false})
    );

    // We generate the contract calls encoded in a compatible way with our multicall contract
    const approveCall = lendingTokenContract.methods.approve(AAVE_POOL_ADDRESS, supplyAmount).encodeABI();
    const supplyCall = aavePoolContract.methods.supply(LENDING_ADDRESS, supplyAmount, accounts[0], 0).encodeABI();
    const interestRateMode = 2;
    const referralCode = 0;
    const borrowCall = aavePoolContract.methods.borrow(
        BORROWED_TOKEN,
        borrowAmount.toString(),
        interestRateMode,
        referralCode,
        accounts[0]
    ).encodeABI();

    const calls = [
        {
          target: LENDING_ADDRESS,
          allowFailure: false,
          value: 0,
          callData: approveCall
        },
        {
          target: AAVE_POOL_ADDRESS,
          allowFailure: false,
          value: 0,
          callData: supplyCall
        },
        {
          target: AAVE_POOL_ADDRESS,
          allowFailure: false,
          value: 0,
          callData: borrowCall
        }
      ];

      // We execute a call directed our ERC-7702 wallet
      const result = await myERC7702Account.methods.aggregate3Value(calls)
      .send({ from: accounts[0], gas: 0, value: 0 })
      .on('transactionHash', function(hash){
        document.getElementById("web3_message").textContent="Executing...";
      })
      .on('receipt', function(receipt){
        document.getElementById("web3_message").textContent="Success.";    })
      .catch((revertReason) => {
        console.log("ERROR! Transaction reverted: " + revertReason.receipt.transactionHash)
      });
}
Enter fullscreen mode Exit fullscreen mode

Test Your dApp

Install Node.js if you don’t have it, I recommend via NVM:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
nvm install 22
# Restart or reload your terminal
Enter fullscreen mode Exit fullscreen mode

Install a web server:

npm i -g lite-server
Enter fullscreen mode Exit fullscreen mode

Then start the frontend:

lite-server
Enter fullscreen mode Exit fullscreen mode

Your web app will be at http://localhost:3000. Once you get your loan you can inspect the bundled transactions in detail on Etherscan and the Aave frontend.

Bundled transactions with EIP-7702
In Etherscan you will be able to see how everything happened in a single transaction.

To continue learning about this and other new features made possible by this upgrade, see the EIP‑7702 Specification.

Thanks for reading this article!

Follow Filosofía Código on dev.to and in Youtube for everything related to Blockchain development.

Top comments (0)