diff --git a/.changeset/strange-phones-help.md b/.changeset/strange-phones-help.md new file mode 100644 index 00000000..d7647b96 --- /dev/null +++ b/.changeset/strange-phones-help.md @@ -0,0 +1,5 @@ +--- +'@protocolink/logics': patch +--- + +add Morphoblue flashloan diff --git a/.env.goerli b/.env.goerli index 6ebb6ebd..44e843ac 100644 --- a/.env.goerli +++ b/.env.goerli @@ -1,3 +1,3 @@ CHAIN_ID=5 HTTP_RPC_URL=https://rpc.ankr.com/eth_goerli -BLOCK_NUMBER=10310000 +BLOCK_NUMBER=10320000 diff --git a/src/logics/morphoblue/abis/MorphoFlashLoanCallback.json b/src/logics/morphoblue/abis/MorphoFlashLoanCallback.json new file mode 100644 index 00000000..398d87a0 --- /dev/null +++ b/src/logics/morphoblue/abis/MorphoFlashLoanCallback.json @@ -0,0 +1,66 @@ +[ + { + "inputs": [ + { "internalType": "address", "name": "router_", "type": "address" }, + { "internalType": "address", "name": "morpho_", "type": "address" }, + { "internalType": "uint256", "name": "feeRate_", "type": "uint256" } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [{ "internalType": "address", "name": "token", "type": "address" }], + "name": "InvalidBalance", + "type": "error" + }, + { "inputs": [], "name": "InvalidCaller", "type": "error" }, + { + "inputs": [], + "name": "feeRate", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "token", "type": "address" }, + { "internalType": "uint256", "name": "assets", "type": "uint256" }, + { "internalType": "bytes", "name": "data", "type": "bytes" } + ], + "name": "flashLoan", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "metadata", + "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "morpho", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "assets", "type": "uint256" }, + { "internalType": "bytes", "name": "data", "type": "bytes" } + ], + "name": "onMorphoFlashLoan", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "router", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + } +] diff --git a/src/logics/morphoblue/configs.ts b/src/logics/morphoblue/configs.ts index 86e454d8..4e328795 100644 --- a/src/logics/morphoblue/configs.ts +++ b/src/logics/morphoblue/configs.ts @@ -1,7 +1,7 @@ import * as common from '@protocolink/common'; import { goerliTokens } from './tokens'; -type ContractNames = 'Morpho'; +type ContractNames = 'Morpho' | 'MorphoFlashLoanCallback'; export interface MarketConfig { id: string; @@ -23,6 +23,7 @@ export const configs: Config[] = [ chainId: common.ChainId.goerli, contract: { Morpho: '0x64c7044050Ba0431252df24fEd4d9635a275CB41', + MorphoFlashLoanCallback: '0x24D5b6b712D1f0D0B628E21E39dBaDde3f28C56e', }, markets: [ { diff --git a/src/logics/morphoblue/contracts/MorphoFlashLoanCallback.ts b/src/logics/morphoblue/contracts/MorphoFlashLoanCallback.ts new file mode 100644 index 00000000..6f972f40 --- /dev/null +++ b/src/logics/morphoblue/contracts/MorphoFlashLoanCallback.ts @@ -0,0 +1,178 @@ +/* Autogenerated file. Do not edit manually. */ +/* tslint:disable */ +/* eslint-disable */ +import type { + BaseContract, + BigNumber, + BigNumberish, + BytesLike, + CallOverrides, + ContractTransaction, + Overrides, + PopulatedTransaction, + Signer, + utils, +} from 'ethers'; +import type { FunctionFragment, Result } from '@ethersproject/abi'; +import type { Listener, Provider } from '@ethersproject/providers'; +import type { TypedEventFilter, TypedEvent, TypedListener, OnEvent } from './common'; + +export interface MorphoFlashLoanCallbackInterface extends utils.Interface { + functions: { + 'feeRate()': FunctionFragment; + 'flashLoan(address,uint256,bytes)': FunctionFragment; + 'metadata()': FunctionFragment; + 'morpho()': FunctionFragment; + 'onMorphoFlashLoan(uint256,bytes)': FunctionFragment; + 'router()': FunctionFragment; + }; + + getFunction( + nameOrSignatureOrTopic: 'feeRate' | 'flashLoan' | 'metadata' | 'morpho' | 'onMorphoFlashLoan' | 'router' + ): FunctionFragment; + + encodeFunctionData(functionFragment: 'feeRate', values?: undefined): string; + encodeFunctionData(functionFragment: 'flashLoan', values: [string, BigNumberish, BytesLike]): string; + encodeFunctionData(functionFragment: 'metadata', values?: undefined): string; + encodeFunctionData(functionFragment: 'morpho', values?: undefined): string; + encodeFunctionData(functionFragment: 'onMorphoFlashLoan', values: [BigNumberish, BytesLike]): string; + encodeFunctionData(functionFragment: 'router', values?: undefined): string; + + decodeFunctionResult(functionFragment: 'feeRate', data: BytesLike): Result; + decodeFunctionResult(functionFragment: 'flashLoan', data: BytesLike): Result; + decodeFunctionResult(functionFragment: 'metadata', data: BytesLike): Result; + decodeFunctionResult(functionFragment: 'morpho', data: BytesLike): Result; + decodeFunctionResult(functionFragment: 'onMorphoFlashLoan', data: BytesLike): Result; + decodeFunctionResult(functionFragment: 'router', data: BytesLike): Result; + + events: {}; +} + +export interface MorphoFlashLoanCallback extends BaseContract { + connect(signerOrProvider: Signer | Provider | string): this; + attach(addressOrName: string): this; + deployed(): Promise; + + interface: MorphoFlashLoanCallbackInterface; + + queryFilter( + event: TypedEventFilter, + fromBlockOrBlockhash?: string | number | undefined, + toBlock?: string | number | undefined + ): Promise>; + + listeners(eventFilter?: TypedEventFilter): Array>; + listeners(eventName?: string): Array; + removeAllListeners(eventFilter: TypedEventFilter): this; + removeAllListeners(eventName?: string): this; + off: OnEvent; + on: OnEvent; + once: OnEvent; + removeListener: OnEvent; + + functions: { + feeRate(overrides?: CallOverrides): Promise<[BigNumber]>; + + flashLoan( + token: string, + assets: BigNumberish, + data: BytesLike, + overrides?: Overrides & { from?: string } + ): Promise; + + metadata(overrides?: CallOverrides): Promise<[string]>; + + morpho(overrides?: CallOverrides): Promise<[string]>; + + onMorphoFlashLoan( + assets: BigNumberish, + data: BytesLike, + overrides?: Overrides & { from?: string } + ): Promise; + + router(overrides?: CallOverrides): Promise<[string]>; + }; + + feeRate(overrides?: CallOverrides): Promise; + + flashLoan( + token: string, + assets: BigNumberish, + data: BytesLike, + overrides?: Overrides & { from?: string } + ): Promise; + + metadata(overrides?: CallOverrides): Promise; + + morpho(overrides?: CallOverrides): Promise; + + onMorphoFlashLoan( + assets: BigNumberish, + data: BytesLike, + overrides?: Overrides & { from?: string } + ): Promise; + + router(overrides?: CallOverrides): Promise; + + callStatic: { + feeRate(overrides?: CallOverrides): Promise; + + flashLoan(token: string, assets: BigNumberish, data: BytesLike, overrides?: CallOverrides): Promise; + + metadata(overrides?: CallOverrides): Promise; + + morpho(overrides?: CallOverrides): Promise; + + onMorphoFlashLoan(assets: BigNumberish, data: BytesLike, overrides?: CallOverrides): Promise; + + router(overrides?: CallOverrides): Promise; + }; + + filters: {}; + + estimateGas: { + feeRate(overrides?: CallOverrides): Promise; + + flashLoan( + token: string, + assets: BigNumberish, + data: BytesLike, + overrides?: Overrides & { from?: string } + ): Promise; + + metadata(overrides?: CallOverrides): Promise; + + morpho(overrides?: CallOverrides): Promise; + + onMorphoFlashLoan( + assets: BigNumberish, + data: BytesLike, + overrides?: Overrides & { from?: string } + ): Promise; + + router(overrides?: CallOverrides): Promise; + }; + + populateTransaction: { + feeRate(overrides?: CallOverrides): Promise; + + flashLoan( + token: string, + assets: BigNumberish, + data: BytesLike, + overrides?: Overrides & { from?: string } + ): Promise; + + metadata(overrides?: CallOverrides): Promise; + + morpho(overrides?: CallOverrides): Promise; + + onMorphoFlashLoan( + assets: BigNumberish, + data: BytesLike, + overrides?: Overrides & { from?: string } + ): Promise; + + router(overrides?: CallOverrides): Promise; + }; +} diff --git a/src/logics/morphoblue/contracts/factories/MorphoFlashLoanCallback__factory.ts b/src/logics/morphoblue/contracts/factories/MorphoFlashLoanCallback__factory.ts new file mode 100644 index 00000000..1dda8a6e --- /dev/null +++ b/src/logics/morphoblue/contracts/factories/MorphoFlashLoanCallback__factory.ts @@ -0,0 +1,150 @@ +/* Autogenerated file. Do not edit manually. */ +/* tslint:disable */ +/* eslint-disable */ + +import { Contract, Signer, utils } from 'ethers'; +import type { Provider } from '@ethersproject/providers'; +import type { MorphoFlashLoanCallback, MorphoFlashLoanCallbackInterface } from '../MorphoFlashLoanCallback'; + +const _abi = [ + { + inputs: [ + { + internalType: 'address', + name: 'router_', + type: 'address', + }, + { + internalType: 'address', + name: 'morpho_', + type: 'address', + }, + { + internalType: 'uint256', + name: 'feeRate_', + type: 'uint256', + }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + inputs: [ + { + internalType: 'address', + name: 'token', + type: 'address', + }, + ], + name: 'InvalidBalance', + type: 'error', + }, + { + inputs: [], + name: 'InvalidCaller', + type: 'error', + }, + { + inputs: [], + name: 'feeRate', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'token', + type: 'address', + }, + { + internalType: 'uint256', + name: 'assets', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + ], + name: 'flashLoan', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'metadata', + outputs: [ + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'morpho', + outputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'uint256', + name: 'assets', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + ], + name: 'onMorphoFlashLoan', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'router', + outputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; + +export class MorphoFlashLoanCallback__factory { + static readonly abi = _abi; + static createInterface(): MorphoFlashLoanCallbackInterface { + return new utils.Interface(_abi) as MorphoFlashLoanCallbackInterface; + } + static connect(address: string, signerOrProvider: Signer | Provider): MorphoFlashLoanCallback { + return new Contract(address, _abi, signerOrProvider) as MorphoFlashLoanCallback; + } +} diff --git a/src/logics/morphoblue/contracts/factories/index.ts b/src/logics/morphoblue/contracts/factories/index.ts index b5a136b1..6016fc49 100644 --- a/src/logics/morphoblue/contracts/factories/index.ts +++ b/src/logics/morphoblue/contracts/factories/index.ts @@ -2,3 +2,4 @@ /* tslint:disable */ /* eslint-disable */ export { Morpho__factory } from './Morpho__factory'; +export { MorphoFlashLoanCallback__factory } from './MorphoFlashLoanCallback__factory'; diff --git a/src/logics/morphoblue/contracts/index.ts b/src/logics/morphoblue/contracts/index.ts index cc50de2e..4871c1db 100644 --- a/src/logics/morphoblue/contracts/index.ts +++ b/src/logics/morphoblue/contracts/index.ts @@ -2,5 +2,7 @@ /* tslint:disable */ /* eslint-disable */ export type { Morpho } from './Morpho'; +export type { MorphoFlashLoanCallback } from './MorphoFlashLoanCallback'; export * as factories from './factories'; export { Morpho__factory } from './factories/Morpho__factory'; +export { MorphoFlashLoanCallback__factory } from './factories/MorphoFlashLoanCallback__factory'; diff --git a/src/logics/morphoblue/index.ts b/src/logics/morphoblue/index.ts index 2b2a60ea..2a3743fe 100644 --- a/src/logics/morphoblue/index.ts +++ b/src/logics/morphoblue/index.ts @@ -3,6 +3,7 @@ export * from './service'; export * from './tokens'; export * from './contracts'; export * from './logic.borrow'; +export * from './logic.flash-loan'; export * from './logic.supply'; export * from './logic.supply-collateral'; export * from './logic.repay'; diff --git a/src/logics/morphoblue/logic.flash-loan.test.ts b/src/logics/morphoblue/logic.flash-loan.test.ts new file mode 100644 index 00000000..98c044fd --- /dev/null +++ b/src/logics/morphoblue/logic.flash-loan.test.ts @@ -0,0 +1,49 @@ +import { FlashLoanLogic, FlashLoanLogicFields } from './logic.flash-loan'; +import { LogicTestCase } from 'test/types'; +import { Morpho__factory } from './contracts'; +import * as common from '@protocolink/common'; +import { constants, utils } from 'ethers'; +import { expect } from 'chai'; +import { getContractAddress } from './configs'; +import { goerliTokens } from './tokens'; + +describe('Morphoblue FlashLoanLogic', function () { + context('Test getTokenList', async function () { + FlashLoanLogic.supportedChainIds.forEach((chainId) => { + it(`network: ${common.toNetworkId(chainId)}`, async function () { + const logic = new FlashLoanLogic(chainId); + const tokenList = await logic.getTokenList(); + expect(tokenList).to.have.lengthOf.above(0); + }); + }); + }); + + context('Test build', function () { + const chainId = common.ChainId.goerli; + const logic = new FlashLoanLogic(chainId); + const iface = Morpho__factory.createInterface(); + + const testCases: LogicTestCase[] = [ + { + fields: { + loans: new common.TokenAmounts([goerliTokens.WETH, '1']), + params: '0x', + }, + }, + ]; + + testCases.forEach(({ fields }) => { + it(`flash loan ${fields.loans.map((loan) => loan.token.symbol).join(',')}`, async function () { + const routerLogic = await logic.build(fields); + const sig = routerLogic.data.substring(0, 10); + + expect(routerLogic.to).to.eq(getContractAddress(chainId, 'MorphoFlashLoanCallback')); + expect(utils.isBytesLike(routerLogic.data)).to.be.true; + expect(sig).to.eq(iface.getSighash('flashLoan')); + expect(routerLogic.inputs).to.deep.eq([]); + expect(routerLogic.approveTo).to.eq(constants.AddressZero); + expect(routerLogic.callback).to.eq(getContractAddress(chainId, 'MorphoFlashLoanCallback')); + }); + }); + }); +}); diff --git a/src/logics/morphoblue/logic.flash-loan.ts b/src/logics/morphoblue/logic.flash-loan.ts new file mode 100644 index 00000000..3e83e340 --- /dev/null +++ b/src/logics/morphoblue/logic.flash-loan.ts @@ -0,0 +1,124 @@ +import { MorphoFlashLoanCallback__factory, Morpho__factory } from './contracts'; +import { Service } from './service'; +import * as common from '@protocolink/common'; +import * as core from '@protocolink/core'; +import { getContractAddress, getMarkets, supportedChainIds } from './configs'; +import invariant from 'tiny-invariant'; + +export type FlashLoanLogicTokenList = common.Token[]; + +export type FlashLoanLogicParams = core.FlashLoanParams; + +export type FlashLoanLogicQuotation = core.FlashLoanQuotation; + +export type FlashLoanLogicFields = core.FlashLoanFields; + +export class FlashLoanLogic extends core.Logic implements core.LogicTokenListInterface, core.LogicBuilderInterface { + static id = 'flash-loan'; + static protocolId = 'morphoblue'; + static readonly supportedChainIds = supportedChainIds; + + get callbackAddress() { + return getContractAddress(this.chainId, 'MorphoFlashLoanCallback'); + } + + async calcCallbackFee(loan: common.TokenAmount) { + const callback = MorphoFlashLoanCallback__factory.connect(this.callbackAddress, this.provider); + const feeRate = await callback.feeRate(); + const callbackFee = new common.TokenAmount(loan.token).setWei(common.calcFee(loan.amountWei, feeRate.toNumber())); + + return callbackFee; + } + + async getTokenList() { + const tokenList: FlashLoanLogicTokenList = []; + const service = new Service(this.chainId, this.provider); + + const markets = getMarkets(this.chainId); + + for (const market of markets) { + const loanTokens = await service.getLoanTokens(market.id); + tokenList.push(...loanTokens!); + } + + return tokenList; + } + + async quote(params: FlashLoanLogicParams) { + const assets = core.isFlashLoanLoanParams(params) + ? params.loans.map(({ token }) => token) + : params.repays.map(({ token }) => token); + invariant(assets.length === 1, 'flashLoan more than one token'); + + const morphoAddress = getContractAddress(this.chainId, 'Morpho'); + const asset = assets[0]; + const calls: common.Multicall3.CallStruct[] = [ + { + target: asset.address, + callData: this.erc20Iface.encodeFunctionData('balanceOf', [morphoAddress]), + }, + ]; + + const { returnData } = await this.multicall3.callStatic.aggregate(calls); + + let j = 0; + const feeBps = 0; + + let loans: common.TokenAmounts; + let repays: common.TokenAmounts; + if (core.isFlashLoanLoanParams(params)) { + ({ loans } = params); + + repays = new common.TokenAmounts(); + for (let i = 0; i < loans.length; i++) { + const loan = loans.at(i); + + const [balance] = this.erc20Iface.decodeFunctionResult('balanceOf', returnData[j]); + const availableToBorrow = new common.TokenAmount(loan.token).setWei(balance); + invariant(availableToBorrow.gte(loan), `insufficient borrowing capacity for the asset: ${loan.token.address}`); + j++; + + const feeAmountWei = common.calcFee(loan.amountWei, feeBps); + const fee = new common.TokenAmount(loan.token).setWei(feeAmountWei); + const repay = loan.clone().add(fee); + repays.add(repay); + } + } else { + loans = new common.TokenAmounts(); + repays = new common.TokenAmounts(); + for (let i = 0; i < params.repays.length; i++) { + const repay = params.repays.at(i); + + const loanAmountWei = common.reverseAmountWithFee(repay.amountWei, feeBps); + const loan = new common.TokenAmount(repay.token).setWei(loanAmountWei); + loans.add(loan); + + const [balance] = this.erc20Iface.decodeFunctionResult('balanceOf', returnData[j]); + const availableToBorrow = new common.TokenAmount(loan.token).setWei(balance); + invariant(availableToBorrow.gte(loan), `insufficient borrowing capacity for the asset: ${loan.token.address}`); + j++; + + const feeAmountWei = common.calcFee(loan.amountWei, feeBps); + const fee = new common.TokenAmount(loan.token).setWei(feeAmountWei); + repays.add(loan.clone().add(fee)); + } + } + + const quotation: FlashLoanLogicQuotation = { loans, repays, feeBps }; + + return quotation; + } + + async build(fields: FlashLoanLogicFields) { + const { loans, params } = fields; + + const to = this.callbackAddress; + const loan = loans.toArray()[0]; + const asset = loan.token.address; + const amount = loan.amountWei; + const data = Morpho__factory.createInterface().encodeFunctionData('flashLoan', [asset, amount, params]); + const callback = this.callbackAddress; + + return core.newLogic({ to, data, callback }); + } +} diff --git a/test/logics/morphoblue/flash-loan.test.ts b/test/logics/morphoblue/flash-loan.test.ts new file mode 100644 index 00000000..2171bc1e --- /dev/null +++ b/test/logics/morphoblue/flash-loan.test.ts @@ -0,0 +1,87 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +import { claimToken, getChainId, snapshotAndRevertEach } from '@protocolink/test-helpers'; +import * as common from '@protocolink/common'; +import * as core from '@protocolink/core'; +import { expect } from 'chai'; +import hre from 'hardhat'; +import * as morphoblue from 'src/logics/morphoblue'; +import * as utility from 'src/logics/utility'; +import * as utils from 'test/utils'; + +describe('goerli: Test Morphoblue FlashLoan Logic', function () { + let chainId: number; + let user: SignerWithAddress; + + before(async function () { + chainId = await getChainId(); + [, user] = await hre.ethers.getSigners(); + + await claimToken( + chainId, + user.address, + morphoblue.goerliTokens.WETH, + '2', + '0x88124Ef4A9EC47e691F254F2E8e348fd1e341e9B' + ); + await claimToken( + chainId, + user.address, + morphoblue.goerliTokens.USDC, + '2', + '0x64c7044050Ba0431252df24fEd4d9635a275CB41' + ); + }); + + snapshotAndRevertEach(); + + const testCases = [ + { loans: new common.TokenAmounts([morphoblue.goerliTokens.WETH, '1']) }, + { repays: new common.TokenAmounts([morphoblue.goerliTokens.WETH, '1']) }, + { loans: new common.TokenAmounts([morphoblue.goerliTokens.USDC, '1']) }, + { repays: new common.TokenAmounts([morphoblue.goerliTokens.USDC, '1']) }, + ]; + + testCases.forEach((params, i) => { + it(`case ${i + 1}`, async function () { + // 1. get flash loan quotation + const morphoblueFlashLoanLogic = new morphoblue.FlashLoanLogic(chainId); + const { loans, repays } = await morphoblueFlashLoanLogic.quote(params); + + // 2. build funds and router logics for flash loan + const funds = new common.TokenAmounts(); + const flashLoanRouterLogics: core.DataType.LogicStruct[] = []; + const utilitySendTokenLogic = new utility.SendTokenLogic(chainId); + for (let i = 0; i < repays.length; i++) { + const loan = loans.at(i); + const repay = repays.at(i); + + const fee = repay.clone().sub(loan); + funds.add(fee); + + const callbackFee = await morphoblueFlashLoanLogic.calcCallbackFee(loan); + funds.add(callbackFee); + repay.add(callbackFee); + + flashLoanRouterLogics.push( + await utilitySendTokenLogic.build({ + input: repay, + recipient: morphoblue.getContractAddress(chainId, 'MorphoFlashLoanCallback'), + }) + ); + } + + // 3. build router logics + const routerLogics: core.DataType.LogicStruct[] = []; + const callbackParams = core.newCallbackParams(flashLoanRouterLogics); + routerLogics.push(await morphoblueFlashLoanLogic.build({ loans, params: callbackParams })); + + // 4. get router permit2 datas + const permit2Datas = await utils.getRouterPermit2Datas(chainId, user, funds.erc20); + + // 5. send router tx + const routerKit = new core.RouterKit(chainId); + const transactionRequest = routerKit.buildExecuteTransactionRequest({ permit2Datas, routerLogics }); + await expect(user.sendTransaction(transactionRequest)).to.not.be.reverted; + }); + }); +});