// Copyright 2026 Liam Lakevold // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. 'use strict' import { LendingProtocol } from '@tetherto/wdk-wallet/protocols' import { BrowserProvider, Contract, Interface, isAddress, JsonRpcProvider, MaxUint256, ZeroAddress, getAddress } from 'ethers' import { EVAULT_ABI, EVC_ABI, OP } from './euler-abis.js' /** @typedef {import('@tetherto/wdk-wallet').TransactionResult} TransactionResult */ /** @typedef {import('@tetherto/wdk-wallet/protocols').BorrowOptions} BorrowOptions */ /** @typedef {import('@tetherto/wdk-wallet/protocols').BorrowResult} BorrowResult */ /** @typedef {import('@tetherto/wdk-wallet/protocols').SupplyOptions} SupplyOptions */ /** @typedef {import('@tetherto/wdk-wallet/protocols').SupplyResult} SupplyResult */ /** @typedef {import('@tetherto/wdk-wallet/protocols').WithdrawOptions} WithdrawOptions */ /** @typedef {import('@tetherto/wdk-wallet/protocols').WithdrawResult} WithdrawResult */ /** @typedef {import('@tetherto/wdk-wallet/protocols').RepayOptions} RepayOptions */ /** @typedef {import('@tetherto/wdk-wallet/protocols').RepayResult} RepayResult */ /** @typedef {import('@tetherto/wdk-wallet').IWalletAccount} IWalletAccount */ /** @typedef {import('@tetherto/wdk-wallet').IWalletAccountReadOnly} IWalletAccountReadOnly */ /** * @typedef {Object} EulerProtocolConfig * @property {Record} [vaults] - Default vault per token address, used by supply, withdraw, borrow and repay when no * 'vault' option is passed. Euler v2 is vault based: every vault holds a single asset and the same asset can have many vaults * with different risk settings, so the integrator chooses which vault a token maps to. * @property {number} [gasLimitBuffer] - Extra gas, in percent of the estimate, set as an explicit gas limit on transactions sent by * standard (non erc-4337) accounts. Euler vaults accrue interest on their first touch in a block, so an estimate made in one block * can fall short when the transaction lands in the next one. Default: 20. Set to 0 to send the raw estimate. */ /** * @typedef {Object} EulerVaultOption * @property {string} [vault] - The Euler vault (EVK) to use for this operation. Overrides the 'vaults' map of the configuration. */ /** @typedef {SupplyOptions & EulerVaultOption} EulerSupplyOptions */ /** @typedef {WithdrawOptions & EulerVaultOption} EulerWithdrawOptions */ /** @typedef {BorrowOptions & EulerVaultOption} EulerBorrowOptions */ /** @typedef {RepayOptions & EulerVaultOption} EulerRepayOptions */ /** * @typedef {Object} AccountData * @property {string[]} controllers - The vaults enabled as controller (at most one; the vault the account borrows from). * @property {string[]} collaterals - The vaults enabled as collateral. * @property {bigint} collateralValue - The account's collateral value adjusted by the controller's liquidation LTVs, in the controller's unit of account. * @property {bigint} borrowCollateralValue - The account's collateral value adjusted by the controller's borrow LTVs (the limit for new borrows). * @property {bigint} liabilityValue - The account's debt value, in the controller's unit of account. * @property {bigint} debt - The account's debt in the controller vault, in the vault asset's base unit. * @property {bigint} healthFactor - collateralValue / liabilityValue scaled by 1e18, on liquidation LTVs like Aave's health factor: * below 1e18 the account can be liquidated. MaxUint256 when the account has no debt. */ const WAD = 10n ** 18n const EVAULT_INTERFACE = new Interface(EVAULT_ABI) const EVC_INTERFACE = new Interface(EVC_ABI) export default class EulerProtocolEvm extends LendingProtocol { /** * Creates a new read-only interface to the Euler v2 protocol for evm blockchains. * * @overload * @param {IWalletAccountReadOnly} account - The wallet account to use to interact with the protocol. * @param {EulerProtocolConfig} [config] - The protocol configuration. */ /** * Creates a new interface to the Euler v2 protocol for evm blockchains. * * @overload * @param {IWalletAccount} account - The wallet account to use to interact with the protocol. * @param {EulerProtocolConfig} [config] - The protocol configuration. */ constructor (account, config = {}) { super(account) /** @private */ this._vaults = {} for (const [token, vault] of Object.entries(config.vaults || {})) { if (!isAddress(token) || !isAddress(vault)) { throw new Error("'vaults' must map token addresses to vault addresses.") } this._vaults[token.toLowerCase()] = getAddress(vault) } const { gasLimitBuffer = 20 } = config if (!Number.isInteger(gasLimitBuffer) || gasLimitBuffer < 0 || gasLimitBuffer > 100) { throw new Error("'gasLimitBuffer' must be an integer percentage between 0 and 100.") } /** @private */ this._gasLimitBuffer = BigInt(gasLimitBuffer) /** @private */ this._evcAddress = undefined /** @private */ this._provider = EulerProtocolEvm._resolveProvider(account) } /** * Reuses the account's own ethers provider when it has one (WDK EVM accounts share one provider per wallet manager, * possibly a failover provider), otherwise builds one from the account's 'provider' configuration. * * @private */ static _resolveProvider (account) { if (account._provider && typeof account._provider.call === 'function') { return account._provider } const provider = account._config && account._config.provider if (!provider) { return undefined } if (typeof provider === 'string') { return new JsonRpcProvider(provider) } if (typeof provider.request === 'function') { return new BrowserProvider(provider) } if (typeof provider.call === 'function') { return provider } return undefined } /** * Deposits a specific token amount into an Euler vault. The account receives vault shares that earn the vault's supply rate. * * Users must first approve the necessary amount of tokens to the vault address using the account's `approve` method. * Use `getVaultAddress(token)` to read the vault address. To use the deposit as collateral, call `setUseVaultAsCollateral`. * * @param {EulerSupplyOptions} options - The supply's options. * @param {Object} [config] - If the protocol has been initialized with an erc-4337 wallet account, it can be used to override * its configuration options. Standard (non erc-4337) accounts silently ignore this config. * @returns {Promise} The supply's result. */ async supply ({ token, amount, onBehalfOf, vault }, config) { this._assertNotReadOnly('supply(options)') this._validateCommon({ token, amount }) this._validateOptionalAddress(onBehalfOf, 'onBehalfOf') const vaultContract = await this._getVault(token, vault) await this._assertTokenBalance(token, amount) await this._assertOperationEnabled(vaultContract, OP.DEPOSIT, 'deposits') const receiver = onBehalfOf || await this._account.getAddress() const maxDeposit = await vaultContract.maxDeposit(receiver) if (maxDeposit < BigInt(amount)) { throw new Error("The vault's supply cap does not allow this amount.") } const supplyTx = await this._getSupplyTransaction(vaultContract, { amount, receiver }) return await this._send(supplyTx, config) } /** * Quotes the costs of a supply operation. * * Users must first approve the necessary amount of tokens to the vault address using the account's `approve` method. * * @param {EulerSupplyOptions} options - The supply's options. * @param {Object} [config] - Optional erc-4337 configuration override. * @returns {Promise>} The supply's costs. */ async quoteSupply ({ token, amount, onBehalfOf, vault }, config) { this._validateCommon({ token, amount }) this._validateOptionalAddress(onBehalfOf, 'onBehalfOf') const vaultContract = await this._getVault(token, vault) const receiver = onBehalfOf || await this._account.getAddress() const supplyTx = await this._getSupplyTransaction(vaultContract, { amount, receiver }) const { fee } = await this._quote(supplyTx, config) return { fee } } /** @private */ async _getSupplyTransaction (vaultContract, { amount, receiver }) { return { to: vaultContract.target, value: 0, data: EVAULT_INTERFACE.encodeFunctionData('deposit', [amount, receiver]) } } /** * Withdraws a specific token amount from an Euler vault. * * Pass `MaxUint256` as the amount to redeem every share the account holds in the vault (a full exit with no dust left). * * @param {EulerWithdrawOptions} options - The withdraw's options. * @param {Object} [config] - Optional erc-4337 configuration override. * @returns {Promise} The withdraw's result. */ async withdraw ({ token, amount, to, vault }, config) { this._assertNotReadOnly('withdraw(options)') this._validateCommon({ token, amount }) this._validateOptionalAddress(to, 'to') const vaultContract = await this._getVault(token, vault) const owner = await this._account.getAddress() const isFullExit = BigInt(amount) === MaxUint256 await this._assertOperationEnabled(vaultContract, isFullExit ? OP.REDEEM : OP.WITHDRAW, 'withdrawals') const evc = await this._getEvcContract() const controllers = await evc.getControllers(owner) const shares = await vaultContract.balanceOf(owner) if (isFullExit && shares === 0n) { throw new Error('The account has no shares in this vault.') } if (controllers.length === 0) { const enough = isFullExit ? (await vaultContract.maxRedeem(owner)) >= shares : (await vaultContract.maxWithdraw(owner)) >= BigInt(amount) if (!enough) { throw new Error('The vault cannot release this amount right now (balance too low or not enough cash in the vault).') } } else { // With any controller enabled, EVK's maxWithdraw and maxRedeem return 0, because account health can only be judged by the // controller at the end of the transaction. The withdrawal is still allowed when the account stays healthy, so here only the // balance and the vault's cash are checked, and the simulation of the transaction checks health before anything is signed. const [cash, assets] = await Promise.all([ vaultContract.cash(), shares === 0n ? 0n : vaultContract.convertToAssets(shares) ]) const needed = isFullExit ? assets : BigInt(amount) if (assets < needed || cash < needed) { throw new Error('The vault cannot release this amount right now (balance too low or not enough cash in the vault).') } } const withdrawTx = await this._getWithdrawTransaction(vaultContract, { amount, receiver: to || owner, owner }) return await this._send(withdrawTx, config) } /** * Quotes the costs of a withdraw operation. * * @param {EulerWithdrawOptions} options - The withdraw's options. * @param {Object} [config] - Optional erc-4337 configuration override. * @returns {Promise>} The withdraw's costs. */ async quoteWithdraw ({ token, amount, to, vault }, config) { this._validateCommon({ token, amount }) this._validateOptionalAddress(to, 'to') const vaultContract = await this._getVault(token, vault) const owner = await this._account.getAddress() const withdrawTx = await this._getWithdrawTransaction(vaultContract, { amount, receiver: to || owner, owner }) const { fee } = await this._quote(withdrawTx, config) return { fee } } /** @private */ async _getWithdrawTransaction (vaultContract, { amount, receiver, owner }) { if (BigInt(amount) === MaxUint256) { const shares = await vaultContract.balanceOf(owner) return { to: vaultContract.target, value: 0, data: EVAULT_INTERFACE.encodeFunctionData('redeem', [shares, receiver, owner]) } } return { to: vaultContract.target, value: 0, data: EVAULT_INTERFACE.encodeFunctionData('withdraw', [amount, receiver, owner]) } } /** * Borrows a specific token amount from an Euler vault. * * The account must have enabled at least one collateral vault accepted by the borrow vault (see `setUseVaultAsCollateral`). * If the borrow vault is not yet the account's controller, it is enabled in the same transaction through an EVC batch. * In Euler v2 debt always belongs to the borrowing account, so `onBehalfOf` (credit delegation) is not supported. * * @param {EulerBorrowOptions} options - The borrow's options. * @param {Object} [config] - Optional erc-4337 configuration override. * @returns {Promise} The borrow's result. */ async borrow ({ token, amount, onBehalfOf, vault }, config) { this._assertNotReadOnly('borrow(options)') this._validateCommon({ token, amount }) const address = await this._account.getAddress() this._assertNoDelegation(onBehalfOf, address) const vaultContract = await this._getVault(token, vault) await this._assertOperationEnabled(vaultContract, OP.BORROW, 'borrows') const cash = await vaultContract.cash() if (cash < BigInt(amount)) { throw new Error('The vault does not have enough liquidity for this borrow.') } const evc = await this._getEvcContract() const [controllers, collaterals] = await Promise.all([evc.getControllers(address), evc.getCollaterals(address)]) if (controllers.length > 0 && controllers[0].toLowerCase() !== vaultContract.target.toLowerCase()) { throw new Error(`The account already borrows from vault '${controllers[0]}'. An Euler account has one controller at a time: repay that debt and call 'disableController' first.`) } if (collaterals.length === 0) { throw new Error("No collateral vault is enabled for this account. Call 'setUseVaultAsCollateral(vault, true)' first.") } const borrowTx = await this._getBorrowTransaction(vaultContract, { amount, address }) return await this._send(borrowTx, config) } /** * Quotes the costs of a borrow operation. * * @param {EulerBorrowOptions} options - The borrow's options. * @param {Object} [config] - Optional erc-4337 configuration override. * @returns {Promise>} The borrow's costs. */ async quoteBorrow ({ token, amount, onBehalfOf, vault }, config) { this._validateCommon({ token, amount }) const address = await this._account.getAddress() this._assertNoDelegation(onBehalfOf, address) const vaultContract = await this._getVault(token, vault) const borrowTx = await this._getBorrowTransaction(vaultContract, { amount, address }) const { fee } = await this._quote(borrowTx, config) return { fee } } /** @private */ async _getBorrowTransaction (vaultContract, { amount, address }) { const evc = await this._getEvcContract() const isController = await evc.isControllerEnabled(address, vaultContract.target) const items = [] if (!isController) { // Calls to the EVC itself inside a batch must use the zero address as 'onBehalfOfAccount'. items.push({ targetContract: evc.target, onBehalfOfAccount: ZeroAddress, value: 0, data: EVC_INTERFACE.encodeFunctionData('enableController', [address, vaultContract.target]) }) } items.push({ targetContract: vaultContract.target, onBehalfOfAccount: address, value: 0, data: EVAULT_INTERFACE.encodeFunctionData('borrow', [amount, address]) }) return { to: evc.target, value: 0, data: EVC_INTERFACE.encodeFunctionData('batch', [items]) } } /** * Repays a specific token amount to an Euler vault. * * Users must first approve the necessary amount of tokens to the vault address using the account's `approve` method. * Pass `MaxUint256` as the amount to repay the whole debt, including interest accrued up to the block of inclusion * (approve a little more than `getAccountData().debt` to cover it). After a full repay, `disableController(vault)` * releases the account's collateral from the vault's control. * * @param {EulerRepayOptions} options - The repay's options. * @param {Object} [config] - Optional erc-4337 configuration override. * @returns {Promise} The repay's result. */ async repay ({ token, amount, onBehalfOf, vault }, config) { this._assertNotReadOnly('repay(options)') this._validateCommon({ token, amount }) this._validateOptionalAddress(onBehalfOf, 'onBehalfOf') const vaultContract = await this._getVault(token, vault) const debtor = onBehalfOf || await this._account.getAddress() const debt = await vaultContract.debtOf(debtor) if (debt === 0n) { throw new Error('The account has no debt in this vault.') } const isFullRepay = BigInt(amount) === MaxUint256 if (!isFullRepay && BigInt(amount) > debt) { throw new Error('The amount is larger than the debt. Pass MaxUint256 to repay the whole debt.') } await this._assertTokenBalance(token, isFullRepay ? debt : amount) await this._assertOperationEnabled(vaultContract, OP.REPAY, 'repays') const repayTx = await this._getRepayTransaction(vaultContract, { amount, debtor }) return await this._send(repayTx, config) } /** * Quotes the costs of a repay operation. * * @param {EulerRepayOptions} options - The repay's options. * @param {Object} [config] - Optional erc-4337 configuration override. * @returns {Promise>} The repay's costs. */ async quoteRepay ({ token, amount, onBehalfOf, vault }, config) { this._validateCommon({ token, amount }) this._validateOptionalAddress(onBehalfOf, 'onBehalfOf') const vaultContract = await this._getVault(token, vault) const debtor = onBehalfOf || await this._account.getAddress() const repayTx = await this._getRepayTransaction(vaultContract, { amount, debtor }) const { fee } = await this._quote(repayTx, config) return { fee } } /** @private */ async _getRepayTransaction (vaultContract, { amount, debtor }) { return { to: vaultContract.target, value: 0, data: EVAULT_INTERFACE.encodeFunctionData('repay', [amount, debtor]) } } /** * Enables or disables a vault as collateral for the account's borrows. The equivalent of Aave's `setUseReserveAsCollateral`. * * @param {string} vault - The collateral vault's address. * @param {boolean} useAsCollateral - True to enable the vault as collateral, false to disable it. * @param {Object} [config] - Optional erc-4337 configuration override. * @returns {Promise} The transaction's result. */ async setUseVaultAsCollateral (vault, useAsCollateral, config) { this._assertNotReadOnly('setUseVaultAsCollateral(vault, useAsCollateral)') if (!isAddress(vault)) { throw new Error("'vault' must be a valid address.") } const address = await this._account.getAddress() await this._assertSameEvc(vault) const evc = await this._getEvcContract() const tx = { to: evc.target, value: 0, data: EVC_INTERFACE.encodeFunctionData(useAsCollateral ? 'enableCollateral' : 'disableCollateral', [address, getAddress(vault)]) } return await this._send(tx, config) } /** * Releases the account from a vault's control. Only succeeds once the account has no debt in that vault. * * @param {string} vault - The controller vault's address. * @param {Object} [config] - Optional erc-4337 configuration override. * @returns {Promise} The transaction's result. */ async disableController (vault, config) { this._assertNotReadOnly('disableController(vault)') if (!isAddress(vault)) { throw new Error("'vault' must be a valid address.") } const vaultContract = new Contract(getAddress(vault), EVAULT_ABI, this._getProvider()) const debt = await vaultContract.debtOf(await this._account.getAddress()) if (debt > 0n) { throw new Error('The account still has debt in this vault. Repay it in full first.') } const tx = { to: vaultContract.target, value: 0, data: EVAULT_INTERFACE.encodeFunctionData('disableController') } return await this._send(tx, config) } /** * Returns the vault this module uses for a token: the 'vaults' map of the configuration, unless a vault is given. * This is the address to approve before a supply or a repay. * * @param {string} token - The token's address. * @param {string} [vault] - An explicit vault, validated against the token. * @returns {Promise} The vault's address. */ async getVaultAddress (token, vault) { if (!isAddress(token)) { throw new Error("'token' must be a valid address.") } const vaultContract = await this._getVault(token, vault) return vaultContract.target } /** * Returns the token amount an account can currently withdraw from its deposit in a vault (shares converted to assets). * * @param {string} token - The token's address. * @param {Object} [options] - Options. * @param {string} [options.vault] - An explicit vault, validated against the token. * @param {string} [options.account] - If set, reads the position of this address instead of the wallet's. * @returns {Promise} The position's value in the token's base unit. */ async getSupplied (token, { vault, account } = {}) { this._validateOptionalAddress(account, 'account') const vaultContract = await this._getVault(token, vault) const owner = account || await this._account.getAddress() const shares = await vaultContract.balanceOf(owner) return shares === 0n ? 0n : await vaultContract.convertToAssets(shares) } /** * Returns this or another account's data: enabled controllers and collaterals, and, when the account borrows, its risk position * as computed by the controller vault. * * @param {string} [account] - If set, returns the account's data for the given address. * @returns {Promise} The account's data. */ async getAccountData (account) { this._validateOptionalAddress(account, 'account') const address = account || await this._account.getAddress() const evc = await this._getEvcContract() const [controllers, collaterals] = await Promise.all([evc.getControllers(address), evc.getCollaterals(address)]) const data = { controllers: [...controllers], collaterals: [...collaterals], collateralValue: 0n, borrowCollateralValue: 0n, liabilityValue: 0n, debt: 0n, healthFactor: MaxUint256 } if (controllers.length === 0) { return data } const controller = new Contract(controllers[0], EVAULT_ABI, this._getProvider()) const [[collateralValue, liabilityValue], [borrowCollateralValue], debt] = await Promise.all([ controller.accountLiquidity(address, true), controller.accountLiquidity(address, false), controller.debtOf(address) ]) data.collateralValue = collateralValue data.borrowCollateralValue = borrowCollateralValue data.liabilityValue = liabilityValue data.debt = debt data.healthFactor = liabilityValue === 0n ? MaxUint256 : (collateralValue * WAD) / liabilityValue return data } /** @private */ async _withGasLimit (tx) { const isErc4337 = Boolean(this._account._config && this._account._config.bundlerUrl) if (this._gasLimitBuffer === 0n || isErc4337 || tx.gasLimit !== undefined) { return tx } const from = await this._account.getAddress() const estimate = await this._getProvider().estimateGas({ from, ...tx }) return { ...tx, gasLimit: (estimate * (100n + this._gasLimitBuffer)) / 100n } } /** @private */ async _send (tx, config) { return await this._account.sendTransaction(await this._withGasLimit(tx), config) } /** @private */ async _quote (tx, config) { return await this._account.quoteSendTransaction(await this._withGasLimit(tx), config) } /** @private */ _getProvider () { if (!this._provider) { throw new Error('The wallet must be connected to a provider to use the Euler protocol.') } return this._provider } /** @private */ _assertNotReadOnly (method) { if (typeof this._account.sendTransaction !== 'function') { throw new Error(`The '${method}' method requires the protocol to be initialized with a non read-only account.`) } } /** @private */ _validateCommon ({ token, amount }) { if (!isAddress(token)) { throw new Error("'token' must be a valid address.") } let value try { value = BigInt(amount) } catch (error) { throw new Error("'amount' must be an integer amount in the token's base unit.") } if (value <= 0n) { throw new Error("'amount' should be greater than zero.") } } /** @private */ _validateOptionalAddress (value, name) { if (value !== undefined && (value === ZeroAddress || !isAddress(value))) { throw new Error(`'${name}' must be a valid address (not zero address).`) } } /** @private */ _assertNoDelegation (onBehalfOf, address) { this._validateOptionalAddress(onBehalfOf, 'onBehalfOf') if (onBehalfOf !== undefined && onBehalfOf.toLowerCase() !== address.toLowerCase()) { throw new Error("Euler v2 does not support borrowing on behalf of another account: 'onBehalfOf' must be omitted or equal the wallet address.") } } /** @private */ async _getVault (token, vault) { const vaultAddress = vault !== undefined ? vault : this._vaults[token.toLowerCase()] if (vaultAddress === undefined) { throw new Error(`No Euler vault is configured for token '${token}'. Pass a 'vault' option or a 'vaults' map in the configuration.`) } if (!isAddress(vaultAddress) || vaultAddress === ZeroAddress) { throw new Error("'vault' must be a valid address (not zero address).") } const vaultContract = new Contract(getAddress(vaultAddress), EVAULT_ABI, this._getProvider()) let asset try { asset = await vaultContract.asset() } catch (error) { throw new Error(`The address '${vaultAddress}' is not an Euler vault on this network.`) } if (asset.toLowerCase() !== token.toLowerCase()) { throw new Error(`The vault '${vaultAddress}' holds '${asset}', not the requested token '${token}'.`) } await this._assertSameEvc(vaultContract.target) return vaultContract } /** @private */ async _assertSameEvc (vault) { const vaultContract = new Contract(getAddress(vault), EVAULT_ABI, this._getProvider()) let evcAddress try { evcAddress = await vaultContract.EVC() } catch (error) { throw new Error(`The address '${vault}' is not an Euler vault on this network.`) } if (this._evcAddress === undefined) { this._evcAddress = evcAddress } else if (this._evcAddress.toLowerCase() !== evcAddress.toLowerCase()) { throw new Error('All vaults used by one protocol instance must share the same Ethereum Vault Connector.') } } /** @private */ async _getEvcContract () { if (this._evcAddress === undefined) { const firstVault = Object.values(this._vaults)[0] if (firstVault === undefined) { throw new Error("The Ethereum Vault Connector is unknown. Configure a 'vaults' map or run an operation with a 'vault' option first.") } await this._assertSameEvc(firstVault) } return new Contract(this._evcAddress, EVC_ABI, this._getProvider()) } /** @private */ async _assertTokenBalance (token, amount) { const tokenBalance = await this._account.getTokenBalance(token) if (tokenBalance < BigInt(amount)) { throw new Error('Not enough funds to fulfill the operation.') } } /** @private */ async _assertOperationEnabled (vaultContract, op, label) { const [hookTarget, hookedOps] = await vaultContract.hookConfig() if (hookTarget === ZeroAddress && (Number(hookedOps) & op) !== 0) { throw new Error(`The vault currently has ${label} disabled.`) } } }