diff --git a/.env.default b/.env.default index 1ffbde42..5684454b 100644 --- a/.env.default +++ b/.env.default @@ -1,4 +1,5 @@ INDEX_0X_API= INDEX_0X_API_KEY= +ARBITRUM_ALCHEMY_APIhttps://arb-mainnet.g.alchemy.com/v2/[key] MAINNET_ALCHEMY_API=https://eth-mainnet.alchemyapi.io/v2/[key] ZEROEX_API_KEY= diff --git a/hardhat.arbitrum.config.js b/hardhat.arbitrum.config.js new file mode 100644 index 00000000..fad44d6e --- /dev/null +++ b/hardhat.arbitrum.config.js @@ -0,0 +1,14 @@ +require('dotenv').config() + +/** @type import('hardhat/config').HardhatUserConfig */ +module.exports = { + solidity: '0.8.17', + networks: { + hardhat: { + chainId: 42161, + forking: { + url: process.env.ARBITRUM_ALCHEMY_API, + }, + }, + }, +} diff --git a/package.json b/package.json index f7e091a5..abd7c724 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "build": "tsup src/index.ts --format cjs,esm --dts --clean", "build:watch": "npm run build -- --watch src", "hardhat": "npx hardhat node", + "hardhat:arbitrum": "npx hardhat node --config hardhat.arbitrum.config.js", "lint": "prettier -c . && eslint ./src", "lint:fix": "prettier -w . && eslint ./src --fix", "test": "jest", diff --git a/src/flashmint/builders/index.ts b/src/flashmint/builders/index.ts index 3bf26bcb..8d089cf8 100644 --- a/src/flashmint/builders/index.ts +++ b/src/flashmint/builders/index.ts @@ -1,3 +1,4 @@ export * from './interface' export * from './leveraged' +export * from './leveraged-extended' export * from './zeroex' diff --git a/src/flashmint/builders/leveraged-extended.test.ts b/src/flashmint/builders/leveraged-extended.test.ts new file mode 100644 index 00000000..b346171a --- /dev/null +++ b/src/flashmint/builders/leveraged-extended.test.ts @@ -0,0 +1,257 @@ +import { BigNumber } from '@ethersproject/bignumber' + +import { ChainId } from 'constants/chains' +import { FlashMintLeveragedExtendedAddress } from 'constants/contracts' +import { IndexCoopEthereum2xIndex } from 'constants/tokens' +import { + collateralDebtSwapData, + debtCollateralSwapData, + inputSwapData, + outputSwapData, +} from 'constants/swapdata' +import { LocalhostProvider, QuoteTokens } from 'tests/utils' +import { getFlashMintLeveragedContractForToken } from 'utils/contracts' +import { wei } from 'utils/numbers' + +import { + FlashMintLeveragedExtendedBuildRequest, + LeveragedExtendedTransactionBuilder, +} from './leveraged-extended' + +const chainId = ChainId.Arbitrum +const provider = LocalhostProvider + +const { eth2x, usdc } = QuoteTokens + +const eth = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' +const usdcAddress = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' + +describe('LeveragedTransactionBuilder()', () => { + const contract = getFlashMintLeveragedContractForToken( + IndexCoopEthereum2xIndex.symbol, + provider, + chainId + ) + + beforeEach((): void => { + jest.setTimeout(10000000) + }) + + test('returns null for invalid request (no index token)', async () => { + const buildRequest = createBuildRequest() + buildRequest.isMinting = true + buildRequest.outputToken = '' + const builder = new LeveragedExtendedTransactionBuilder(provider) + const tx = await builder.build(buildRequest) + expect(tx).toBeNull() + }) + + test('returns null for invalid request (no input/output token)', async () => { + const buildRequest = createBuildRequest() + buildRequest.inputToken = '' + const builder = new LeveragedExtendedTransactionBuilder(provider) + const tx = await builder.build(buildRequest) + expect(tx).toBeNull() + }) + + test('returns null for invalid request (inputTokenAmount = 0)', async () => { + const buildRequest = createBuildRequest() + buildRequest.inputTokenAmount = BigNumber.from(0) + const builder = new LeveragedExtendedTransactionBuilder(provider) + const tx = await builder.build(buildRequest) + expect(tx).toBeNull() + }) + + test('returns null for invalid request (outputTokenAmount = 0)', async () => { + const buildRequest = createBuildRequest() + buildRequest.outputTokenAmount = BigNumber.from(0) + const builder = new LeveragedExtendedTransactionBuilder(provider) + const tx = await builder.build(buildRequest) + expect(tx).toBeNull() + }) + + test('returns null for invalid request (invalid swap data debt collateral - no pool)', async () => { + const buildRequest = createBuildRequest() + buildRequest.swapDataDebtCollateral = { + exchange: 1, + path: ['', ''], + fees: [], + pool: '', + } + const builder = new LeveragedExtendedTransactionBuilder(provider) + const tx = await builder.build(buildRequest) + expect(tx).toBeNull() + }) + + test('returns null for invalid request (invalid swap data input/output token - no paths)', async () => { + const buildRequest = createBuildRequest() + buildRequest.swapDataInputOutputToken = { + exchange: 1, + path: [], + fees: [], + pool: '0x0000000000000000000000000000000000000000', + } + const builder = new LeveragedExtendedTransactionBuilder(provider) + const tx = await builder.build(buildRequest) + expect(tx).toBeNull() + }) + + test('returns null for invalid request (invalid swap data input/output token - univ3 fees)', async () => { + const buildRequest = createBuildRequest() + buildRequest.swapDataInputOutputToken = { + exchange: 3, + path: ['', '', ''], + // For UniV3 fees.length has to be path.length - 1 + fees: [3000], + pool: '0x0000000000000000000000000000000000000000', + } + const builder = new LeveragedExtendedTransactionBuilder(provider) + const tx = await builder.build(buildRequest) + expect(tx).toBeNull() + }) + + test('returns null for invalid request (invalid path - exchange type none)', async () => { + const buildRequest = createBuildRequest() + buildRequest.swapDataInputOutputToken = { + exchange: 0, + path: [], + fees: [500], + pool: '0x00000000000000000000000000000000000000', + } + const builder = new LeveragedExtendedTransactionBuilder(provider) + const tx = await builder.build(buildRequest) + expect(tx).toBeNull() + }) + + // TODO: check + test.skip('returns tx for correct swap data with exchange type none', async () => { + const buildRequest = createBuildRequest() + buildRequest.swapDataInputOutputToken = { + exchange: 0, + path: [], + fees: [500], + pool: '0x0000000000000000000000000000000000000000', + } + const builder = new LeveragedExtendedTransactionBuilder(provider) + const tx = await builder.build(buildRequest) + expect(tx).not.toBeNull() + }) + + // FIXME: + test.skip('returns a tx for minting ETH2X (ERC20)', async () => { + const buildRequest = createBuildRequest() + buildRequest.isMinting = true + const indexToken = buildRequest.outputToken + // TODO: figure out + const swapDataInputTokenForETH = inputSwapData['icETH']['ETH'] + const priceEstimateInflator = BigNumber.from(0) + const maxDust = BigNumber.from(0) + const refTx = await contract.populateTransaction.issueSetFromExactERC20( + indexToken, + buildRequest.outputTokenAmount, + buildRequest.inputToken, + buildRequest.inputTokenAmount, + buildRequest.swapDataDebtCollateral, + buildRequest.swapDataInputOutputToken, + swapDataInputTokenForETH, + priceEstimateInflator, + maxDust + ) + const builder = new LeveragedExtendedTransactionBuilder(provider) + const tx = await builder.build(buildRequest) + if (!tx) fail() + expect(tx.to).toBe(FlashMintLeveragedExtendedAddress) + expect(tx.data).toEqual(refTx.data) + }) + + test('returns a tx for minting ETH2X (ETH)', async () => { + const buildRequest = createBuildRequest(true, eth, 'ETH') + const indexToken = buildRequest.outputToken + const priceEstimateInflator = BigNumber.from(0) + const maxDust = BigNumber.from(0) + const refTx = await contract.populateTransaction.issueSetFromExactETH( + indexToken, + buildRequest.outputTokenAmount, + buildRequest.swapDataDebtCollateral, + buildRequest.swapDataInputOutputToken, + priceEstimateInflator, + maxDust, + { value: buildRequest.inputTokenAmount } + ) + const builder = new LeveragedExtendedTransactionBuilder(provider) + const tx = await builder.build(buildRequest) + if (!tx) fail() + expect(tx.to).toBe(FlashMintLeveragedExtendedAddress) + expect(tx.data).toEqual(refTx.data) + expect(tx.value).toEqual(buildRequest.inputTokenAmount) + }) + + test('returns a tx for redeeming ETH2X (ERC20)', async () => { + const buildRequest = createBuildRequest( + false, + IndexCoopEthereum2xIndex.addressArbitrum!, + IndexCoopEthereum2xIndex.symbol, + usdcAddress, + 'USDC' + ) + const refTx = await contract.populateTransaction.redeemExactSetForERC20( + buildRequest.inputToken, + buildRequest.inputTokenAmount, + buildRequest.outputToken, + buildRequest.outputTokenAmount, + buildRequest.swapDataDebtCollateral, + buildRequest.swapDataInputOutputToken + ) + const builder = new LeveragedExtendedTransactionBuilder(provider) + const tx = await builder.build(buildRequest) + if (!tx) fail() + expect(tx.to).toBe(FlashMintLeveragedExtendedAddress) + expect(tx.data).toEqual(refTx.data) + }) + + test('returns a tx for redeeming ETH2X (ETH)', async () => { + const buildRequest = createBuildRequest( + false, + IndexCoopEthereum2xIndex.addressArbitrum!, + IndexCoopEthereum2xIndex.symbol, + eth, + 'ETH' + ) + const refTx = await contract.populateTransaction.redeemExactSetForETH( + buildRequest.inputToken, + buildRequest.inputTokenAmount, + buildRequest.outputTokenAmount, + buildRequest.swapDataDebtCollateral, + buildRequest.swapDataInputOutputToken + ) + const builder = new LeveragedExtendedTransactionBuilder(provider) + const tx = await builder.build(buildRequest) + if (!tx) fail() + expect(tx.to).toBe(FlashMintLeveragedExtendedAddress) + expect(tx.data).toEqual(refTx.data) + }) +}) + +function createBuildRequest( + isMinting = true, + inputToken: string = usdcAddress, + inputTokenSymbol = 'USDC', + outputToken: string = IndexCoopEthereum2xIndex.addressArbitrum!, + outputTokenSymbol: string = IndexCoopEthereum2xIndex.symbol +): FlashMintLeveragedExtendedBuildRequest { + return { + isMinting, + inputToken, + inputTokenSymbol, + outputToken, + outputTokenSymbol, + inputTokenAmount: wei(1), + outputTokenAmount: BigNumber.from(194235680), + swapDataDebtCollateral: isMinting + ? collateralDebtSwapData['icETH'] + : debtCollateralSwapData['icETH'], + swapDataInputOutputToken: isMinting + ? inputSwapData['icETH']['ETH'] + : outputSwapData['icETH']['ETH'], + } +} diff --git a/src/flashmint/builders/leveraged-extended.ts b/src/flashmint/builders/leveraged-extended.ts new file mode 100644 index 00000000..8e92a886 --- /dev/null +++ b/src/flashmint/builders/leveraged-extended.ts @@ -0,0 +1,152 @@ +import { TransactionRequest } from '@ethersproject/abstract-provider' +import { BigNumber } from '@ethersproject/bignumber' +import { JsonRpcProvider } from '@ethersproject/providers' + +import { getFlashMintLeveragedContractForToken } from '../../utils/contracts' +import { Exchange, SwapData } from '../../utils/swapData' + +import { TransactionBuilder } from './interface' +import { isEmptyString, isInvalidAmount } from './utils' + +export interface FlashMintLeveragedExtendedBuildRequest { + isMinting: boolean + inputToken: string + inputTokenSymbol: string + outputToken: string + outputTokenSymbol: string + inputTokenAmount: BigNumber + outputTokenAmount: BigNumber + swapDataDebtCollateral: SwapData + swapDataInputOutputToken: SwapData +} + +export class LeveragedExtendedTransactionBuilder + implements + TransactionBuilder< + FlashMintLeveragedExtendedBuildRequest, + TransactionRequest + > +{ + constructor(private readonly provider: JsonRpcProvider) {} + + async build( + request: FlashMintLeveragedExtendedBuildRequest + ): Promise { + const isValidRequest = this.isValidRequest(request) + if (!isValidRequest) return null + const { + inputToken, + inputTokenAmount, + inputTokenSymbol, + outputToken, + outputTokenAmount, + outputTokenSymbol, + isMinting, + swapDataDebtCollateral, + swapDataInputOutputToken, + } = request + const network = await this.provider.getNetwork() + const chainId = network.chainId + const indexToken = isMinting ? outputToken : inputToken + const indexTokenSymbol = isMinting ? outputTokenSymbol : inputTokenSymbol + const contract = getFlashMintLeveragedContractForToken( + indexTokenSymbol, + this.provider, + chainId + ) + if (isMinting) { + const isInputTokenEth = inputTokenSymbol === 'ETH' + // TODO: + const minIndexTokenAmount = outputTokenAmount + const priceEstimateInflator = BigNumber.from(0) + const maxDust = BigNumber.from(0) + if (isInputTokenEth) { + return await contract.populateTransaction.issueSetFromExactETH( + indexToken, + minIndexTokenAmount, + swapDataDebtCollateral, + swapDataInputOutputToken, + priceEstimateInflator, + maxDust, + { value: inputTokenAmount } + ) + } else { + // TODO: + const minIndexTokenAmount = outputTokenAmount + const swapDataInputTokenForETH = {} + const priceEstimateInflator = BigNumber.from(0) + const maxDust = BigNumber.from(0) + return await contract.populateTransaction.issueSetFromExactERC20( + indexToken, + minIndexTokenAmount, + inputToken, + inputTokenAmount, + swapDataDebtCollateral, + swapDataInputOutputToken, + swapDataInputTokenForETH, + priceEstimateInflator, + maxDust + ) + } + } else { + const isOutputTokenEth = outputTokenSymbol === 'ETH' + if (isOutputTokenEth) { + return await contract.populateTransaction.redeemExactSetForETH( + indexToken, + inputTokenAmount, + outputTokenAmount, // _minAmountOutputToken + swapDataDebtCollateral, + swapDataInputOutputToken + ) + } else { + return await contract.populateTransaction.redeemExactSetForERC20( + indexToken, + inputTokenAmount, + outputToken, + outputTokenAmount, // _minAmountOutputToken + swapDataDebtCollateral, + swapDataInputOutputToken + ) + } + } + } + + private isValidSwapData(swapData: SwapData): boolean { + if (swapData.exchange === Exchange.None) { + if (swapData.pool.length !== 42) return false + return true + } + if ( + swapData.exchange === Exchange.UniV3 && + swapData.fees.length !== swapData.path.length - 1 + ) + return false + if (swapData.path.length === 0) return false + if (swapData.pool.length !== 42) return false + return true + } + + private isValidRequest( + request: FlashMintLeveragedExtendedBuildRequest + ): boolean { + const { + inputToken, + inputTokenAmount, + inputTokenSymbol, + outputToken, + outputTokenAmount, + outputTokenSymbol, + swapDataDebtCollateral, + swapDataInputOutputToken, + } = request + if (isEmptyString(inputToken)) return false + if (isEmptyString(inputTokenSymbol)) return false + if (isEmptyString(outputToken)) return false + if (isEmptyString(outputTokenSymbol)) return false + if (isInvalidAmount(inputTokenAmount)) return false + if (isInvalidAmount(outputTokenAmount)) return false + if (!this.isValidSwapData(swapDataDebtCollateral)) return false + if (!this.isValidSwapData(swapDataInputOutputToken)) return false + return true + } +}