// Offline unit tests: input validation, read-only guards, vault resolution and transaction encoding. // The vault and the EVC are replaced with in-memory fakes; see tests/fork for the run against real Euler vaults. import { beforeEach, describe, expect, jest, test } from '@jest/globals' import { Interface, MaxUint256, ZeroAddress } from 'ethers' import EulerProtocolEvm from '../../index.js' import { EVAULT_ABI, EVC_ABI } from '../../src/euler-abis.js' const ME = '0x1111111111111111111111111111111111111111' const OTHER = '0x2222222222222222222222222222222222222222' const TOKEN = '0x3333333333333333333333333333333333333333' const VAULT = '0x4444444444444444444444444444444444444444' const EVC = '0x5555555555555555555555555555555555555555' const vaultIface = new Interface(EVAULT_ABI) const evcIface = new Interface(EVC_ABI) function fakeVault (overrides = {}) { return { target: VAULT, hookConfig: async () => [ZeroAddress, 0n], maxDeposit: async () => MaxUint256, maxWithdraw: async () => MaxUint256, maxRedeem: async () => MaxUint256, balanceOf: async () => 10n, convertToAssets: async (s) => s, cash: async () => MaxUint256, debtOf: async () => 5n, ...overrides } } function fakeEvc (overrides = {}) { return { target: EVC, getControllers: async () => [], getCollaterals: async () => [OTHER], isControllerEnabled: async () => false, ...overrides } } describe('EulerProtocolEvm', () => { let account, protocol, vault, evc beforeEach(() => { account = { _config: {}, getAddress: jest.fn(async () => ME), getTokenBalance: jest.fn(async () => MaxUint256), sendTransaction: jest.fn(async () => ({ hash: '0xabc', fee: 7n })), quoteSendTransaction: jest.fn(async () => ({ fee: 7n })) } protocol = new EulerProtocolEvm(account, { vaults: { [TOKEN]: VAULT }, gasLimitBuffer: 0 }) vault = fakeVault() evc = fakeEvc() protocol._getVault = jest.fn(async () => vault) protocol._getEvcContract = jest.fn(async () => evc) }) describe('constructor', () => { test('rejects a malformed vaults map', () => { expect(() => new EulerProtocolEvm(account, { vaults: { foo: VAULT } })).toThrow("'vaults' must map") }) test('reuses the account provider when it has one', () => { const provider = { call: async () => '0x' } const p = new EulerProtocolEvm({ ...account, _provider: provider }) expect(p._provider).toBe(provider) }) test('builds a provider from a url string', () => { const p = new EulerProtocolEvm({ ...account, _config: { provider: 'http://127.0.0.1:8545' } }) expect(typeof p._provider.call).toBe('function') }) }) describe('input validation', () => { const cases = [ ['supply', { token: 'nope', amount: 1n }, "'token' must be a valid address."], ['supply', { token: TOKEN, amount: 0n }, "'amount' should be greater than zero."], ['supply', { token: TOKEN, amount: 1n, onBehalfOf: ZeroAddress }, "'onBehalfOf' must be a valid address"], ['withdraw', { token: TOKEN, amount: -1n }, "'amount' should be greater than zero."], ['withdraw', { token: TOKEN, amount: 1n, to: 'nope' }, "'to' must be a valid address"], ['borrow', { token: TOKEN, amount: 1n, onBehalfOf: OTHER }, 'does not support borrowing on behalf'], ['repay', { token: TOKEN, amount: 0 }, "'amount' should be greater than zero."], ['supply', { token: TOKEN, amount: 1.5 }, "'amount' must be an integer"], ['quoteSupply', { token: 'nope', amount: 1n }, "'token' must be a valid address."], ['quoteBorrow', { token: TOKEN, amount: 1n, onBehalfOf: OTHER }, 'does not support borrowing on behalf'] ] test.each(cases)('%s rejects invalid options (%#)', async (method, options, message) => { await expect(protocol[method](options)).rejects.toThrow(message) expect(account.sendTransaction).not.toHaveBeenCalled() }) }) describe('read-only accounts', () => { test.each(['supply', 'withdraw', 'borrow', 'repay'])('%s requires a full account', async (method) => { delete account.sendTransaction await expect(protocol[method]({ token: TOKEN, amount: 1n })).rejects.toThrow('non read-only account') }) test('quotes still work', async () => { delete account.sendTransaction await expect(protocol.quoteSupply({ token: TOKEN, amount: 1n })).resolves.toEqual({ fee: 7n }) }) }) describe('supply', () => { test('sends vault.deposit(amount, wallet)', async () => { await expect(protocol.supply({ token: TOKEN, amount: 100n })).resolves.toEqual({ hash: '0xabc', fee: 7n }) const [tx] = account.sendTransaction.mock.calls[0] expect(tx.to).toBe(VAULT) expect(vaultIface.decodeFunctionData('deposit', tx.data).map(String)).toEqual(['100', ME]) }) test('deposits for another receiver', async () => { await protocol.supply({ token: TOKEN, amount: 100n, onBehalfOf: OTHER }) const [tx] = account.sendTransaction.mock.calls[0] expect(vaultIface.decodeFunctionData('deposit', tx.data)[1]).toBe(OTHER) }) test('rejects when the balance is too low', async () => { account.getTokenBalance.mockResolvedValue(99n) await expect(protocol.supply({ token: TOKEN, amount: 100n })).rejects.toThrow('Not enough funds') }) test('rejects when the supply cap is reached', async () => { vault.maxDeposit = async () => 50n await expect(protocol.supply({ token: TOKEN, amount: 100n })).rejects.toThrow('supply cap') }) test('rejects when deposits are disabled by governance', async () => { vault.hookConfig = async () => [ZeroAddress, 1n] await expect(protocol.supply({ token: TOKEN, amount: 100n })).rejects.toThrow('deposits disabled') }) test('does not treat a hooked operation as disabled', async () => { vault.hookConfig = async () => [OTHER, 1n] await expect(protocol.supply({ token: TOKEN, amount: 100n })).resolves.toBeDefined() }) test('forwards the erc-4337 config', async () => { const config = { paymasterToken: { address: TOKEN } } await protocol.supply({ token: TOKEN, amount: 1n }, config) expect(account.sendTransaction.mock.calls[0][1]).toBe(config) }) }) describe('withdraw', () => { test('sends vault.withdraw(amount, wallet, wallet)', async () => { await protocol.withdraw({ token: TOKEN, amount: 3n }) const [tx] = account.sendTransaction.mock.calls[0] expect(vaultIface.decodeFunctionData('withdraw', tx.data).map(String)).toEqual(['3', ME, ME]) }) test('MaxUint256 redeems every share', async () => { await protocol.withdraw({ token: TOKEN, amount: MaxUint256, to: OTHER }) const [tx] = account.sendTransaction.mock.calls[0] expect(vaultIface.decodeFunctionData('redeem', tx.data).map(String)).toEqual(['10', OTHER, ME]) }) test('rejects a full exit with no shares', async () => { vault.balanceOf = async () => 0n await expect(protocol.withdraw({ token: TOKEN, amount: MaxUint256 })).rejects.toThrow('no shares') }) test('rejects more than the vault can release', async () => { vault.maxWithdraw = async () => 2n await expect(protocol.withdraw({ token: TOKEN, amount: 3n })).rejects.toThrow('cannot release') }) test('with a controller enabled, a full exit is allowed although EVK reports maxRedeem = 0', async () => { evc.getControllers = async () => [OTHER] vault.maxRedeem = async () => 0n await protocol.withdraw({ token: TOKEN, amount: MaxUint256 }) const [tx] = account.sendTransaction.mock.calls[0] expect(vaultIface.decodeFunctionData('redeem', tx.data).map(String)).toEqual(['10', ME, ME]) vault.cash = async () => 9n await expect(protocol.withdraw({ token: TOKEN, amount: MaxUint256 })).rejects.toThrow('cannot release') }) test('with a controller enabled, checks balance and cash instead of maxWithdraw', async () => { evc.getControllers = async () => [OTHER] vault.maxWithdraw = async () => 0n await expect(protocol.withdraw({ token: TOKEN, amount: 3n })).resolves.toBeDefined() await expect(protocol.withdraw({ token: TOKEN, amount: 11n })).rejects.toThrow('cannot release') }) }) describe('borrow', () => { test('batches enableController and borrow when the vault is not yet the controller', async () => { await protocol.borrow({ token: TOKEN, amount: 9n }) const [tx] = account.sendTransaction.mock.calls[0] expect(tx.to).toBe(EVC) const [items] = evcIface.decodeFunctionData('batch', tx.data) expect(items).toHaveLength(2) expect(items[0].targetContract).toBe(EVC) expect(items[0].onBehalfOfAccount).toBe(ZeroAddress) expect(evcIface.decodeFunctionData('enableController', items[0].data).map(String)).toEqual([ME, VAULT]) expect(items[1].targetContract).toBe(VAULT) expect(items[1].onBehalfOfAccount).toBe(ME) expect(vaultIface.decodeFunctionData('borrow', items[1].data).map(String)).toEqual(['9', ME]) }) test('skips enableController when already enabled', async () => { evc.isControllerEnabled = async () => true await protocol.borrow({ token: TOKEN, amount: 9n }) const [items] = evcIface.decodeFunctionData('batch', account.sendTransaction.mock.calls[0][0].data) expect(items).toHaveLength(1) }) test('rejects a borrow from a second vault while another vault is the controller', async () => { evc.getControllers = async () => [OTHER] await expect(protocol.borrow({ token: TOKEN, amount: 9n })).rejects.toThrow('one controller at a time') }) test('allows a further borrow from the current controller', async () => { evc.getControllers = async () => [VAULT] await expect(protocol.borrow({ token: TOKEN, amount: 9n })).resolves.toBeDefined() }) test('rejects without collateral', async () => { evc.getCollaterals = async () => [] await expect(protocol.borrow({ token: TOKEN, amount: 9n })).rejects.toThrow('No collateral vault') }) test('rejects when the vault lacks cash', async () => { vault.cash = async () => 8n await expect(protocol.borrow({ token: TOKEN, amount: 9n })).rejects.toThrow('enough liquidity') }) test('accepts onBehalfOf equal to the wallet', async () => { await expect(protocol.borrow({ token: TOKEN, amount: 9n, onBehalfOf: ME })).resolves.toBeDefined() }) }) describe('repay', () => { test('sends vault.repay(amount, wallet)', async () => { await protocol.repay({ token: TOKEN, amount: 4n }) const [tx] = account.sendTransaction.mock.calls[0] expect(vaultIface.decodeFunctionData('repay', tx.data).map(String)).toEqual(['4', ME]) }) test('MaxUint256 checks the balance against the current debt', async () => { account.getTokenBalance.mockResolvedValue(4n) await expect(protocol.repay({ token: TOKEN, amount: MaxUint256 })).rejects.toThrow('Not enough funds') account.getTokenBalance.mockResolvedValue(5n) await expect(protocol.repay({ token: TOKEN, amount: MaxUint256 })).resolves.toBeDefined() }) test('rejects an amount above the debt, pointing to MaxUint256', async () => { await expect(protocol.repay({ token: TOKEN, amount: 6n })).rejects.toThrow('larger than the debt') }) test('rejects when there is no debt', async () => { vault.debtOf = async () => 0n await expect(protocol.repay({ token: TOKEN, amount: 1n })).rejects.toThrow('no debt') }) }) describe('gas limit buffer', () => { test('rejects an invalid buffer', () => { expect(() => new EulerProtocolEvm(account, { gasLimitBuffer: -1 })).toThrow('gasLimitBuffer') expect(() => new EulerProtocolEvm(account, { gasLimitBuffer: 1.5 })).toThrow('gasLimitBuffer') }) test('adds 20% to the estimate by default for standard accounts, in sends and quotes', async () => { const estimateGas = jest.fn(async () => 100_000n) const p = new EulerProtocolEvm({ ...account, _provider: { call: async () => '0x', estimateGas } }, { vaults: { [TOKEN]: VAULT } }) p._getVault = jest.fn(async () => vault) await p.supply({ token: TOKEN, amount: 1n }) expect(account.sendTransaction.mock.calls[0][0].gasLimit).toBe(120_000n) expect(estimateGas.mock.calls[0][0].from).toBe(ME) await p.quoteSupply({ token: TOKEN, amount: 1n }) expect(account.quoteSendTransaction.mock.calls[0][0].gasLimit).toBe(120_000n) }) test('leaves erc-4337 accounts to their bundler', async () => { const estimateGas = jest.fn(async () => 100_000n) const p = new EulerProtocolEvm({ ...account, _config: { bundlerUrl: 'https://bundler' }, _provider: { call: async () => '0x', estimateGas } }, { vaults: { [TOKEN]: VAULT } }) p._getVault = jest.fn(async () => vault) await p.supply({ token: TOKEN, amount: 1n }) expect(account.sendTransaction.mock.calls[0][0].gasLimit).toBeUndefined() expect(estimateGas).not.toHaveBeenCalled() }) }) describe('vault resolution', () => { test('requires a vault for unknown tokens', async () => { const p = new EulerProtocolEvm({ ...account, _provider: { call: async () => '0x' } }) await expect(p.getVaultAddress(TOKEN)).rejects.toThrow('No Euler vault is configured') }) }) })