// End-to-end test against real Euler v2 vaults on an Ethereum mainnet fork. // // Start a fork first, then run the suite: // anvil --fork-url --port 18547 --chain-id 1 // FORK_RPC=http://127.0.0.1:18547 npm run test:fork // // The wallet is a WDK EVM wallet (@tetherto/wdk-wallet-evm) built from a random throwaway mnemonic. import { afterAll, beforeAll, describe, expect, test } from '@jest/globals' import WalletManagerEvm from '@tetherto/wdk-wallet-evm' import { AbiCoder, Contract, JsonRpcProvider, MaxUint256, Wallet, keccak256, parseEther, toBeHex, zeroPadValue } from 'ethers' import EulerProtocolEvm, { EULER_PRESETS } from '../../index.js' const RPC = process.env.FORK_RPC || 'http://127.0.0.1:18547' // A fresh random mnemonic per run, used on the local fork only. (The well known 'test ... junk' mnemonic is not used: // its first address has real mainnet history and balances, which would leak into a fork.) const MNEMONIC = Wallet.createRandom().mnemonic.phrase const WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' const USDT = '0xdAC17F958D2ee523a2206206994597C13D831ec7' const E_WETH = EULER_PRESETS[1][WETH] const E_USDT = EULER_PRESETS[1][USDT] const ERC20 = ['function balanceOf(address) view returns (uint256)'] const EVAULT = [ 'function balanceOf(address) view returns (uint256)', 'function debtOf(address) view returns (uint256)', 'function governorAdmin() view returns (address)', 'function hookConfig() view returns (address, uint32)', 'function setHookConfig(address newHookTarget, uint32 newHookedOps)' ] describe('EulerProtocolEvm on a mainnet fork (Euler Prime WETH collateral, USD₮ borrow)', () => { let provider, wallet, account, address, protocol, weth, usdt, eWeth, eUsdt async function mined (result) { expect(result.hash).toMatch(/^0x[0-9a-f]{64}$/) expect(typeof result.fee).toBe('bigint') const receipt = await provider.waitForTransaction(result.hash) expect(receipt.status).toBe(1) // anvil mines instantly, but ethers providers cache identical RPC reads (eth_getTransactionCount included) for 250 ms. // Without a pause the next send could reuse a cached nonce. Real chains have blocks far longer than that. await new Promise(resolve => setTimeout(resolve, 300)) return receipt } async function setUsdtBalance (holder, amount) { // USDT keeps balances in a mapping at storage slot 2. const slot = keccak256(AbiCoder.defaultAbiCoder().encode(['address', 'uint256'], [holder, 2])) await provider.send('anvil_setStorageAt', [USDT, slot, zeroPadValue(toBeHex(amount), 32)]) } beforeAll(async () => { provider = new JsonRpcProvider(RPC) const { chainId } = await provider.getNetwork() if (chainId !== 1n) throw new Error(`Expected a mainnet fork (chain id 1), got ${chainId}`) const { timestamp } = await provider.getBlock('latest') if (timestamp > Math.floor(Date.now() / 1000) + 3600) { throw new Error('The fork clock is ahead of real time (a previous run advanced it). Restart anvil for a fresh fork.') } wallet = new WalletManagerEvm(MNEMONIC, { provider: RPC }) account = await wallet.getAccount(0) address = await account.getAddress() await provider.send('anvil_setBalance', [address, toBeHex(parseEther('10'))]) weth = new Contract(WETH, ERC20, provider) usdt = new Contract(USDT, ERC20, provider) eWeth = new Contract(E_WETH, EVAULT, provider) eUsdt = new Contract(E_USDT, EVAULT, provider) // Wrap 2 ETH into WETH with a plain value transfer from the WDK account. await mined(await account.sendTransaction({ to: WETH, value: parseEther('2') })) protocol = new EulerProtocolEvm(account, { vaults: EULER_PRESETS[1] }) }) afterAll(() => { if (wallet) wallet.dispose() if (provider) provider.destroy() }) test('resolves the configured vault for a token and rejects a mismatched vault', async () => { expect(await protocol.getVaultAddress(WETH)).toBe(E_WETH) await expect(protocol.getVaultAddress(WETH, E_USDT)).rejects.toThrow('not the requested token') await expect(protocol.getVaultAddress(WETH, WETH)).rejects.toThrow('not an Euler vault') }) test('quotes and performs a supply of 1 WETH', async () => { await mined(await account.approve({ token: WETH, spender: E_WETH, amount: parseEther('1') })) const { fee } = await protocol.quoteSupply({ token: WETH, amount: parseEther('1') }) expect(fee).toBeGreaterThan(0n) await mined(await protocol.supply({ token: WETH, amount: parseEther('1') })) const supplied = await protocol.getSupplied(WETH) expect(supplied).toBeGreaterThanOrEqual(parseEther('1') - 2n) expect(supplied).toBeLessThanOrEqual(parseEther('1')) expect(await weth.balanceOf(address)).toBe(parseEther('1')) }) test('refuses to borrow before any collateral is enabled', async () => { await expect(protocol.borrow({ token: USDT, amount: 100_000_000n })).rejects.toThrow('No collateral vault is enabled') }) test('enables the WETH vault as collateral', async () => { await mined(await protocol.setUseVaultAsCollateral(E_WETH, true)) const data = await protocol.getAccountData() expect(data.collaterals).toEqual([E_WETH]) expect(data.controllers).toEqual([]) expect(data.healthFactor).toBe(MaxUint256) }) test('rejects a borrow beyond the collateral limit at simulation, before signing', async () => { // 1 WETH at an 84% borrow LTV supports far less than 100,000 USD₮. await expect(protocol.borrow({ token: USDT, amount: 100_000_000_000n })).rejects.toThrow() expect((await protocol.getAccountData()).controllers).toEqual([]) }) test('borrows 500 USD₮ in one transaction that also enables the controller', async () => { const { fee } = await protocol.quoteBorrow({ token: USDT, amount: 500_000_000n }) expect(fee).toBeGreaterThan(0n) await mined(await protocol.borrow({ token: USDT, amount: 500_000_000n })) expect(await usdt.balanceOf(address)).toBe(500_000_000n) const data = await protocol.getAccountData() expect(data.controllers).toEqual([E_USDT]) expect(data.collaterals).toEqual([E_WETH]) expect(data.debt).toBeGreaterThanOrEqual(500_000_000n) expect(data.liabilityValue).toBeGreaterThan(0n) expect(data.healthFactor).toBeGreaterThan(10n ** 18n) // The health factor uses liquidation LTVs (86% here), the borrow limit uses borrow LTVs (84%). expect(data.collateralValue).toBeGreaterThan(data.borrowCollateralValue) }) test('refuses a borrow from a second vault while eUSDT-2 is the controller', async () => { const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' await expect(protocol.borrow({ token: USDC, amount: 1_000_000n })).rejects.toThrow('one controller at a time') }) test('refuses borrowing on behalf of another account', async () => { await expect(protocol.borrow({ token: USDT, amount: 1n, onBehalfOf: E_WETH })).rejects.toThrow('does not support borrowing on behalf') }) test('rejects withdrawing collateral that would leave the account unhealthy', async () => { await expect(protocol.withdraw({ token: WETH, amount: parseEther('0.99') })).rejects.toThrow() expect(await protocol.getSupplied(WETH)).toBeGreaterThan(parseEther('0.99')) }) test('refuses to disable the controller while debt is open', async () => { await expect(protocol.disableController(E_USDT)).rejects.toThrow('still has debt') }) test('repays the full debt with MaxUint256, including accrued interest', async () => { // Let an hour of interest accrue (short enough to keep the oracle feeds fresh), then top up 1 USD₮ to cover it. await provider.send('evm_increaseTime', [3600]) await provider.send('evm_mine', []) await setUsdtBalance(address, 501_000_000n) const debtBefore = await eUsdt.debtOf(address) expect(debtBefore).toBeGreaterThan(500_000_000n) await mined(await account.approve({ token: USDT, spender: E_USDT, amount: 501_000_000n })) await expect(protocol.repay({ token: USDT, amount: debtBefore + 1_000_000n })).rejects.toThrow('larger than the debt') const { fee } = await protocol.quoteRepay({ token: USDT, amount: MaxUint256 }) expect(fee).toBeGreaterThan(0n) await mined(await protocol.repay({ token: USDT, amount: MaxUint256 })) expect(await eUsdt.debtOf(address)).toBe(0n) const left = await usdt.balanceOf(address) expect(left).toBeLessThan(1_000_000n) expect(left).toBeGreaterThan(0n) }) test('refuses a repay when there is no debt', async () => { await expect(protocol.repay({ token: USDT, amount: 1n })).rejects.toThrow('no debt') }) test('withdraws a partial amount, then exits fully with MaxUint256, while the controller is still enabled', async () => { // EVK reports maxWithdraw = maxRedeem = 0 for any account with a controller; the module must not block these. expect((await protocol.getAccountData()).controllers).toEqual([E_USDT]) await mined(await protocol.withdraw({ token: WETH, amount: parseEther('0.25') })) expect(await weth.balanceOf(address)).toBe(parseEther('1.25')) const { fee } = await protocol.quoteWithdraw({ token: WETH, amount: MaxUint256 }) expect(fee).toBeGreaterThan(0n) await mined(await protocol.withdraw({ token: WETH, amount: MaxUint256 })) expect(await eWeth.balanceOf(address)).toBe(0n) // An hour of supply interest accrued on the deposit, so the full exit returns at least what went in, less rounding. expect(await weth.balanceOf(address)).toBeGreaterThanOrEqual(parseEther('2') - 2n) }) test('disables the controller once the debt is zero', async () => { await mined(await protocol.disableController(E_USDT)) expect((await protocol.getAccountData()).controllers).toEqual([]) }) test('detects a vault operation disabled by governance before sending', async () => { const governor = await eWeth.governorAdmin() const [hookTarget, hookedOps] = await eWeth.hookConfig() await provider.send('anvil_impersonateAccount', [governor]) await provider.send('anvil_setBalance', [governor, toBeHex(parseEther('1'))]) // Sends as the impersonated governor with a raw eth_sendTransaction (anvil signs for impersonated accounts). const asGovernor = async (target, op) => { const data = eWeth.interface.encodeFunctionData('setHookConfig', [target, op]) const hash = await provider.send('eth_sendTransaction', [{ from: governor, to: E_WETH, data, gas: toBeHex(500000) }]) const receipt = await provider.getTransactionReceipt(hash) expect(receipt.status).toBe(1) } try { // Disable deposits only (OP_DEPOSIT = 1 << 0), with no hook target. await asGovernor('0x0000000000000000000000000000000000000000', 1) await mined(await account.approve({ token: WETH, spender: E_WETH, amount: parseEther('0.1') })) await expect(protocol.supply({ token: WETH, amount: parseEther('0.1') })).rejects.toThrow('deposits disabled') // Withdrawals are a different flag and must not be reported as disabled. await expect(protocol.withdraw({ token: WETH, amount: 1n })).rejects.toThrow('cannot release') } finally { await asGovernor(hookTarget, hookedOps) await provider.send('anvil_stopImpersonatingAccount', [governor]) } expect(await eWeth.hookConfig()).toEqual([hookTarget, hookedOps]) }) test('works with a read-only account for quotes and reads, and blocks writes', async () => { const readOnly = await account.toReadOnlyAccount() const viewer = new EulerProtocolEvm(readOnly, { vaults: EULER_PRESETS[1] }) expect((await viewer.getAccountData()).controllers).toEqual([]) await expect(viewer.supply({ token: WETH, amount: 1n })).rejects.toThrow('non read-only account') }) })