Skip to Content
docs

Repay Loans on Aave V4

Repaying borrowed assets allows you to:

  • Reduce or eliminate your debt position
  • Improve your position’s health factor and reduce liquidation risk
  • Free up collateral for withdrawal or additional borrowing
  • Close your borrow position entirely when repaying the full amount

Repay Loans

Repaying a loan can be broken down into the following steps:

  1. Identify the borrow position to repay
  2. Preview the impact of the repay operation
  3. Repay the borrowed assets

Identify the Borrow Position

Given a list of the user’s borrow positions, identify the position (token) you want to reduce and choose the repayment amount (partial or full).

You can’t repay while the reserve is paused.

For example, let’s say you have identified the following UserBorrowItem object.

Example UserBorrowItem
const borrowPosition: UserBorrowItem = {
  reserve: {
    id: "SGVsbG8h",
    onChainId: "42",
    chain: {
      chainId: 1,
      name: "Ethereum",
    },
    spoke: {
      address: "0x123…",
      // …
    },
    asset: {
      underlying: {
        address: "0xa0b86a33e6e2ad05ad6c9ac3b6e5e5f6e7b6c1b2", // USDC
      },
      // …
    },
    status: {
      paused: false,
    },
    // … other reserve properties
  },
  debt: {
    amount: {
      value: BigDecimal(512.023456), // ~512 USDC debt
      // …
    },
    // …
  },
  // …
};

Preview Repay with AaveKit

Preview the impact of a repay operation before committing to it.

Use the usePreview hook (or the imperative usePreviewAction variant) in React, the preview action in TypeScript, or the preview query in GraphQL to preview the impact of the repay operation on the user’s position.

import { type RepayRequest, usePreview } from "@aave/react";

function RepayPreview({ request }: { request: RepayRequest }) {
  const { data, error, loading } = usePreview({
    action: {
      repay: request,
    },
  });

  if (loading) return <div>Loading…</div>;
  if (error) return <div>Error: {error.message}</div>;

  // data: PreviewUserPosition
  return (
    <div>
      <h3>Health Factor:</h3>
      <p>From: {data.healthFactor.current?.toFixed(2) ?? "N/A"}</p>
      <p>To: {data.healthFactor.after?.toFixed(2) ?? "N/A"}</p>

      <h3>User Risk Premium:</h3>
      <p>From: {data.riskPremium.current.normalized.toFixed(2)}%</p>
      <p>To: {data.riskPremium.after.normalized.toFixed(2)}%</p>

      <h3>Net APY:</h3>
      <p>From: {data.netApy.current.normalized.toFixed(2)}%</p>
      <p>To: {data.netApy.after.normalized.toFixed(2)}%</p>

      <h3>Net Collateral:</h3>
      <p>From: {data.netCollateral.current.value.toDisplayString(2)}</p>
      <p>To: {data.netCollateral.after.value.toDisplayString(2)}</p>
    </div>
  );
}
  • React: The RepayRequest can be as follows.
  • GraphQL: The PreviewRequest can be as follows.
const request: RepayRequest = {
  sender: evmAddress("0x789…"), // User's address
  reserve: borrowPosition.reserve.id,
  amount: {
    erc20: {
      value: {
        exact: bigDecimal(250), // 250 USDC
      },
    },
  },
};

The PreviewUserPosition shows the impact of the repay operation by comparing current and after states, with the table below outlining key fields and how to interpret them.

FieldImpact
healthFactor.[current → after]: BigDecimal|nullHigher is better
(null if not applicable)
riskPremium.[current → after]: PercentNumberLower is better
netApy.[current → after]: PercentNumberHigher is better
netCollateral.[current → after]: ExchangeAmountHigher is better
netBalance.[current → after]: ExchangeAmountUpdated balance
maxBorrowingPower.[current → after]: ExchangeAmountMaximum borrowing power
remainingBorrowingPower.[current → after]: ExchangeAmountRemaining borrowing power

You can also specify a different currency to return fiat amounts in.

import { Currency } from "@aave/react";

const { data, error, loading } = usePreview({
  action: {
    repay: request,
  },
  currency: Currency.Eur,
});

Preview Repay with Solidity

Start with the spoke address and on-chain reserve ID identified:

Target Reserve
address spokeAddress = 0x123…;
uint256 reserveId = 42;

And an amount to repay:

Amount to Repay
uint256 amountToRepay = 42e6; // 42 USDC

Follow the steps below to preview the impact of a repay operation on the user’s position.

1. Get Current Account Data

First, retrieve the user’s current account data from the Spoke contract.

Get Account Data
import { ISpoke } from "aave-v4/src/spoke/interfaces/ISpoke.sol";

ISpoke spoke = ISpoke(spokeAddress);
ISpoke.UserAccountData memory currentData = spoke.getUserAccountData(user);

2. Calculate Repay Value

Then, retrieve the reserve data and asset price from the oracle to calculate the USD value of the repayment amount.

Repay Value
address oracleAddress = spoke.ORACLE();
ISpoke.Reserve memory reserve = spoke.getReserve(reserveId);
uint256 assetPrice = IAaveOracle(oracleAddress).getReservePrice(reserveId);
uint256 repayValue = amountToRepay * assetPrice * 10 ** (18 - reserve.decimals);

3. Compute New Health Factor

Finally, calculate the new health factor by reducing the total debt value.

Compute New Health Factor
uint256 totalDebtValue = currentData.totalDebtValueRay / 1e27;
if (repayValue >= totalDebtValue) return type(uint256).max;
uint256 newHealthFactor = (currentData.totalCollateralValue * currentData.avgCollateralFactor) / (totalDebtValue - repayValue); // WAD

Complete Preview Example

Here’s a complete Solidity example that calculates the health factor before a repay operation:

Complete Health Factor Preview
import { ISpoke } from "aave-v4/src/spoke/interfaces/ISpoke.sol";
import { IAaveOracle } from "aave-v4/src/spoke/interfaces/IAaveOracle.sol";

function previewRepayHealthFactor(
  address spokeAddress,
  uint256 reserveId,
  uint256 amountToRepay,
  address user
) external view returns (uint256) {
  ISpoke spoke = ISpoke(spokeAddress);

  // Step 1: Get current account data
  ISpoke.UserAccountData memory currentData = spoke.getUserAccountData(user);

  // Step 2: Calculate repay value
  ISpoke.Reserve memory reserve = spoke.getReserve(reserveId);
  uint256 assetPrice = IAaveOracle(spoke.ORACLE()).getReservePrice(reserveId);
  uint256 repayValue = amountToRepay * assetPrice * 10 ** (18 - reserve.decimals);

  // Step 3: Compute new health factor
  uint256 totalDebtValue = currentData.totalDebtValueRay / 1e27;
  if (repayValue >= totalDebtValue) return type(uint256).max;
  return (currentData.totalCollateralValue * currentData.avgCollateralFactor) / (totalDebtValue - repayValue); // WAD
}

Step-by-Step with AaveKit

Repay the selected borrow position.

Repaying ERC-20 tokens requires token approval. You can choose between two approaches:

  • Transaction-based approval: Sends a separate ERC-20 approve() transaction before the repay transaction (2 transactions total).
  • Permit-based approval: Signs an EIP-2612 permit to approve and repay in a single transaction. More gas-efficient, but requires the token to support permits.

To repay a loan with AaveKit, follow these steps.

1. Configure Wallet Integration

In React, first instantiate the hooks for the wallet library of your choice:

  • useSendTransaction: used to send ERC-20 approval and repay transactions
  • useSignTypedData: used to sign ERC-20 permits when available
Viem
import { useWalletClient } from "wagmi";
import { useSendTransaction, useSignTypedData } from "@aave/react/viem";

// …

const { data: wallet } = useWalletClient();
const [sendTransaction] = useSendTransaction(wallet);
const [signTypedData] = useSignTypedData(wallet);

2. Define the Repay Flow

Then, in React, use the useRepay hook to prepare the repay operation.

import { useRepay } from "@aave/react";

const [repay, { loading, error }] = useRepay((plan) => {
  switch (plan.__typename) {
    case "TransactionRequest":
      return sendTransaction(plan);

    case "Erc20Approval":
      // If token supports EIP-2612 permits, sign permit (recommended)
      if (plan.bySignature) {
        return signTypedData(plan.bySignature);
      }
      // Otherwise use traditional approval transaction
      return sendTransaction(plan.byTransaction);

    case "PreContractActionRequired":
      return sendTransaction(plan.transaction);
  }
});

In the Erc20Approval case, the bySignature field is only available if the token supports EIP-2612 permits.

For tokens that need an allowance reset, like USDT on Ethereum Mainnet, the hook calls your callback twice: reset to 0, then set the new value. bySignature is null for these.

3. Execute the Repay Operation

Then, execute the desired repay operation.

  • TypeScript: Use the repay action to prepare the repay execution plan.
  • GraphQL: Use the repay query to prepare the repay execution plan.
import { bigDecimal, evmAddress } from "@aave/react";

const execute = async () => {
  const result = await repay({
    sender: evmAddress(wallet.account.address), // User's address
    reserve: borrowPosition.reserve.id,
    amount: {
      erc20: {
        value: {
          exact: bigDecimal(250), // Repay 250 USDC
        },
      },
    },
  });

  // …
};

In GraphQL, below are some examples of RepayRequest objects.

{
  "request": {
    "sender": "0x742d35cc6e5c4ce3b69a2a8c7c8e5f7e9a0b1234",
    "reserve": "SGVsbG8h",
    "amount": {
      "erc20": {
        "value": {
          "exact": "250"
        }
      }
    }
  }
}

In TypeScript, to handle ERC-20 permit-based approval, first get the execution plan, sign the permit typed data when available, then call repay again with both the signature and signed amount. Unlike supply, repay also needs the signedAmount from the permit typed data, so permitWith cannot be used here.

import { repay } from "@aave/client/actions";
import { signTypedDataWith } from "@aave/client/viem";
import { bigDecimal, evmAddress, okAsync } from "@aave/client";

import { client } from "./client";
import { wallet } from "./wallet";

const result = await repay(client, {
  sender: evmAddress(wallet.account.address),
  reserve: borrowPosition.reserve.id,
  amount: {
    erc20: {
      value: {
        exact: bigDecimal(250), // Repay 250 USDC
      },
    },
  },
})
  .andThen((plan) => {
    if (
      plan.__typename !== "Erc20ApprovalRequired" ||
      plan.approvals.length !== 1 ||
      !plan.approvals[0].bySignature
    ) {
      return okAsync(plan);
    }

    const permitTypedData = plan.approvals[0].bySignature;
    return signTypedDataWith(wallet, permitTypedData).andThen((signature) =>
      repay(client, {
        sender: evmAddress(wallet.account.address),
        reserve: borrowPosition.reserve.id,
        amount: {
          erc20: {
            value: {
              exact: bigDecimal(250),
            },
            permit: {
              permitSig: {
                deadline: permitTypedData.message.deadline as number,
                value: signature,
              },
              signedAmount: permitTypedData.signedAmount,
            },
          },
        },
      }),
    );
  });

// …

The permit-based repay flow:

  1. Call repay without a permit
  2. If the response is Erc20ApprovalRequired with a single approvals[0].bySignature, sign it
  3. Call repay again with amount.erc20.permit.permitSig and amount.erc20.permit.signedAmount
  4. Otherwise, keep the original execution plan (no permit needed or token doesn’t support permits)

For tokens that need an allowance reset, like USDT on Ethereum Mainnet, permits aren’t available and the execution plan uses transaction-based approvals.

4. Process the Execution Plan

In TypeScript, use sendWith for the wallet library of your choice to send the transactions in the execution plan, and await indexing with client.waitForTransaction.

import { repay } from "@aave/client/actions";
import { sendWith } from "@aave/client/viem";

import { client } from "./client";
import { wallet } from "./wallet";

const result = await repay(client, {
  // …
})
  .andThen(sendWith(wallet))
  .andThen(client.waitForTransaction);

sendWith handles all required approvals, including the allowance reset for tokens like USDT.

In GraphQL, if the response is a TransactionRequest, you can send it directly using the wallet associated with the from address.

TransactionRequest
{
  "data": {
    "repay": {
      "__typename": "TransactionRequest",
      "to": "0x94e7a5dcbe816e498b89ab752661904e2f56c485",
      "from": "0x742d35cc6e5c4ce3b69a2a8c7c8e5f7e9a0b1234",
      "data": "0xb1e8f8ef…",
      "value": "0",
      "chainId": 1,
      "operations": ["SPOKE_REPAY"]
    }
  }
}

If the response is Erc20ApprovalRequired, you have two options:

Transaction-based approval

  1. Send each transaction in the approvals array in sequence
  2. Then send originalTransaction

Permit-based approval

This is available only when approvals contains a single Erc20Approval item.

  1. Sign the approvals[0].bySignature typed data with the sender’s account.
  2. Call the repay query again with both the permit signature and signedAmount (see the Permit-based Approval example above)
  3. The response will be a TransactionRequest you can then send directly
{
  "data": {
    "repay": {
      "__typename": "Erc20ApprovalRequired",
      "reason": "Insufficient allowance for ERC-20 token transfer",
      "requiredAmount": {
        "value": "250.000000",
        "decimals": 6
      },
      "currentAllowance": {
        "value": "0.000000",
        "decimals": 6
      },
      "approvals": [
        {
          "byTransaction": {
            "to": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
            "from": "0x742d35cc6e5c4ce3b69a2a8c7c8e5f7e9a0b1234",
            "data": "0x095ea7b3000000000000000000000000878…",
            "value": "0",
            "chainId": 1,
            "operations": null
          },
          "bySignature": {
            "types": { /* … */ },
            "primaryType": "Permit",
            "domain": { /* … */ },
            "message": { /* … */ },
            "signedAmount": "250.000000"
          }
        }
      ],
      "originalTransaction": {
        "to": "0x94e7a5dcbe816e498b89ab752661904e2f56c485",
        "from": "0x742d35cc6e5c4ce3b69a2a8c7c8e5f7e9a0b1234",
        "data": "0xb1e8f8ef…",
        "value": "0",
        "chainId": 1,
        "operations": ["SPOKE_REPAY"]
      }
    }
  }
}

With multiple approvals in Erc20ApprovalRequired, send each byTransaction in order, then originalTransaction. For USDT on Ethereum Mainnet, the first resets the allowance to 0 and the second sets the required amount.

If the response is PreContractActionRequired, you must first send the transaction request, then send the originalTransaction.

PreContractActionRequired
{
  "data": {
    "repay": {
      "__typename": "PreContractActionRequired",
      "reason": "Authorize the native wrapping gateway to operate on behalf of the user",
      "transaction": {
        "to": "0x94e7a5dcbe816e498b89ab752661904e2f56c485",
        "from": "0x742d35cc6e5c4ce3b69a2a8c7c8e5f7e9a0b1234",
        "data": "0x095ea7b3000000000000000000000000878…",
        "value": "0",
        "chainId": 1,
        "operations": null
      },
      "originalTransaction": {
        "to": "0x94e7a5dcbe816e498b89ab752661904e2f56c485",
        "from": "0x742d35cc6e5c4ce3b69a2a8c7c8e5f7e9a0b1234",
        "data": "0xb1e8f8ef…",
        "value": "0",
        "chainId": 1,
        "operations": ["SPOKE_REPAY"]
      }
    }
  }
}

If the response is an InsufficientBalanceError, inform the user that they don’t have enough tokens to repay the requested amount.

InsufficientBalanceError
{
  "data": {
    "repay": {
      "__typename": "InsufficientBalanceError",
      "required": {
        "value": "250.000000",
        "decimals": 6
      },
      "available": {
        "value": "150.500000",
        "decimals": 6
      }
    }
  }
}

5. Handle the Result

Finally, in React and TypeScript, handle the result.

Example
const execute = async () => {
  const result = await repay(/* … */);

  if (result.isErr()) {
    switch (result.error.name) {
      case "CancelError":
        // The user cancelled the operation
        return;

      case "SigningError":
        console.error(
          `Failed to sign the transaction: ${result.error.message}`,
        );
        break;

      case "TimeoutError":
        console.error(`Transaction timed out: ${result.error.message}`);
        break;

      case "TransactionError":
        console.error(`Transaction failed: ${result.error.message}`);
        break;

      case "ValidationError":
        console.error(
          "Insufficient balance:",
          `required: ${result.error.cause.required.value.toDisplayString(2)}`,
          `available: ${result.error.cause.available.value.toDisplayString(2)}`,
        );
        break;

      case "UnexpectedError":
        console.error(result.error.message);
        break;
    }
    return;
  }

  console.log("Repay successful with hash:", result.value.txHash);
};

Step-by-Step with Solidity

Use the same identifiers and amount to repay used to preview the repay operation:

Target Reserve
address spokeAddress = 0x123…;
uint256 reserveId = 42;
uint256 amountToRepay = 42e6; // 42 USDC

Complete the repay process with the following steps:

1. Import the Spoke Interface

First, create an instance of the ISpoke interface.

Import the Spoke Interface
import { ISpoke } from "aave-v4/src/spoke/interfaces/ISpoke.sol";

ISpoke spoke = ISpoke(spokeAddress);

2. Approve Token Transfer

Then, approve the Spoke to transfer the specified amount of ERC-20 tokens.

Approve Token Transfer
import { IERC20 } from "aave-v4/src/dependencies/openzeppelin/IERC20.sol";

IERC20 token = IERC20(spoke.getReserve(reserveId).underlying);
token.approve(spokeAddress, amountToRepay);

3. Repay Tokens

Finally, call the repay function on the Spoke contract, specifying the reserve ID, amount, and the user address.

spoke.repay(
  reserveId,
  amountToRepay,
  msg.sender
);

Complete Repay Example

Here’s a complete Solidity example that executes the full repay flow:

Complete Repay Flow
import { ISpoke } from "aave-v4/src/spoke/interfaces/ISpoke.sol";
import { IERC20 } from "aave-v4/src/dependencies/openzeppelin/IERC20.sol";

function repayDebt(
  address spokeAddress,
  uint256 reserveId,
  uint256 amountToRepay,
  address onBehalfOf
) external {
  ISpoke spoke = ISpoke(spokeAddress);

  // Step 1: Pull tokens from the caller and approve the Spoke
  IERC20 token = IERC20(spoke.getReserve(reserveId).underlying);
  token.transferFrom(msg.sender, address(this), amountToRepay);
  token.approve(spokeAddress, amountToRepay);

  // Step 2: Repay debt
  spoke.repay(reserveId, amountToRepay, onBehalfOf);
}

The contract must be an active Position Manager approved by onBehalfOf, otherwise repay reverts.


Advanced Usage

Network Fee

This experimental React hook only works with Viem or Wagmi integrations.

Estimate the network cost of any action using the same PreviewAction you pass to the usePreview hook.

For example:

PreviewAction
import { type PreviewAction } from "@aave/react";

const action: PreviewAction = {
  repay: {
    sender: evmAddress("0x123…"), // User's address
    reserve: borrowPosition.reserve.id,
    amount: {
      erc20: {
        value: { exact: bigDecimal(42) }, // 42 USDC
      },
    },
  },
};

Use the useNetworkFee hook to estimate both the network fee for the provided action and its fiat equivalent.

Viem
import { type PreviewAction, Currency } from "@aave/react";
import { useNetworkFee } from "@aave/react/viem";

function NetworkFee({ action }: { action: PreviewAction }) {
  const {
    data: fee,
    loading,
    error,
  } = useNetworkFee({
    query: { estimate: action },
    currency: Currency.Eur,
  });

  if (loading) return <p>Loading fee…</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <p>
      Network Fee: {fee.amount.value.toDisplayString(2)} {fee.token.info.symbol}
      <span>
        ≈{fee.exchange.symbol}
        {fee.exchange.value.toDisplayString(2)}
      </span>
    </p>
  );
}

Native Tokens with AaveKit

When the Reserve’s underlying token is the wrapped version of the chain’s native token (e.g., WETH on Ethereum), you can repay the debt using the chain’s native token with the Native Token Gateway.

Use the reserve.asset.underlying.isWrappedNativeToken flag to determine if the underlying token is a wrapped native token. The Native Token Gateway address is available from the chain details.

WETH Borrow Position
const borrowPosition: UserBorrowItem = {
  reserve: {
    id: "SGVsbG8h",
    onChainId: "42",
    asset: {
      underlying: {
        address: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
        info: {
          name: "Wrapped Ether",
          symbol: "WETH",
          decimals: 18,
          // …
        },
        isWrappedNativeToken: true,
        // …
      },
      // …
    },
    spoke: {
      address: "0x123…",
      // …
    },
    chain: {
      chainId: 1,
      name: "Ethereum",
      nativeGateway: "0xabc…",
    },
    // …
  },
  // …
};

Specify the amount in the amount field as a native value.

const execute = async () => {
  const result = await repay({
    sender: evmAddress(wallet.account.address), // User's address
    reserve: borrowPosition.reserve.id,
    amount: {
      native: {
        exact: bigDecimal(1), // Repay 1 ETH
      },
    },
  });

  // …
};

Native Tokens with Solidity

Start with the spoke address, reserve ID, and native gateway address identified:

Native Token Gateway
address spokeAddress = 0x123…;
uint256 reserveId = 42;
address nativeGateway = 0xabc…;

Follow the steps below to repay debt with native tokens (e.g., ETH) for a wrapped native token reserve (e.g., WETH).

1. Approve the Gateway

First, approve the Native Token Gateway as your position manager. This is a one-time setup step.

Approve Gateway
import { ISpoke } from "aave-v4/src/spoke/interfaces/ISpoke.sol";

ISpoke spoke = ISpoke(spokeAddress);
spoke.setUserPositionManager(nativeGateway, true);

2. Repay Native Tokens

Then, call repayNative on the gateway, sending the native token amount via msg.value.

import { INativeTokenGateway } from "aave-v4/src/position-manager/interfaces/INativeTokenGateway.sol";

INativeTokenGateway gateway = INativeTokenGateway(nativeGateway);
gateway.repayNative{value: amountToRepay}(spokeAddress, reserveId, amountToRepay);

The gateway wraps the native token (ETH → WETH), approves the Spoke, and calls spoke.repay on your behalf. Any amount above the current debt is refunded to you.

Complete Native Token Example

Here’s a complete Solidity example that executes the native token repay flow. This helper acts on the contract’s own position. To repay for a user’s wallet, the user calls setUserPositionManager and the gateway directly.

Complete Native Repay Flow
import { ISpoke } from "aave-v4/src/spoke/interfaces/ISpoke.sol";
import { INativeTokenGateway } from "aave-v4/src/position-manager/interfaces/INativeTokenGateway.sol";

function repayNativeToken(
  address spokeAddress,
  address nativeGateway,
  uint256 reserveId,
  uint256 amountToRepay
) external payable {
  require(msg.value == amountToRepay, "Incorrect ETH amount");

  ISpoke spoke = ISpoke(spokeAddress);

  // Step 1: Approve gateway as position manager (one-time setup)
  if (!spoke.isPositionManager(address(this), nativeGateway)) {
    spoke.setUserPositionManager(nativeGateway, true);
  }

  // Step 2: Repay native tokens via gateway
  INativeTokenGateway(nativeGateway).repayNative{value: amountToRepay}(spokeAddress, reserveId, amountToRepay);
}