diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f616c98d..7bb55ecb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -43,6 +43,7 @@ jobs: - run: npm run test:builders - run: npm run test:quotes - run: npm run test:base + - run: npm run test:icusd - run: npm run test:hyeth # - run: npm run test:btc2x # - run: npm run test:eth2x diff --git a/package-lock.json b/package-lock.json index 56252462..45ade96c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@indexcoop/flash-mint-sdk", - "version": "3.3.2", + "version": "3.7.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@indexcoop/flash-mint-sdk", - "version": "3.3.2", + "version": "3.7.1", "license": "MIT", "dependencies": { "@ethersproject/abstract-provider": "^5.6.1", diff --git a/package.json b/package.json index 1cd6a9b2..60dca085 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "test:eth2x": "npm test src/tests/eth2x.test.ts", "test:hyeth": "npm test src/tests/hyeth.test.ts", "test:iceth": "npm test src/tests/iceth", + "test:icusd": "npm test src/tests/icusd.test.ts", "test:watch": "jest --watch" }, "keywords": [ diff --git a/src/constants/abis/FlashMintWrapped.json b/src/constants/abis/FlashMintWrapped.json new file mode 100644 index 00000000..c588211f --- /dev/null +++ b/src/constants/abis/FlashMintWrapped.json @@ -0,0 +1 @@ +[{"inputs":[{"components":[{"internalType":"address","name":"quickRouter","type":"address"},{"internalType":"address","name":"sushiRouter","type":"address"},{"internalType":"address","name":"uniV3Router","type":"address"},{"internalType":"address","name":"uniV3Quoter","type":"address"},{"internalType":"address","name":"curveAddressProvider","type":"address"},{"internalType":"address","name":"curveCalculator","type":"address"},{"internalType":"address","name":"balV2Vault","type":"address"},{"internalType":"address","name":"weth","type":"address"}],"internalType":"struct DEXAdapterV3.Addresses","name":"_dexAddresses","type":"tuple"},{"internalType":"contract IController","name":"_setController","type":"address"},{"internalType":"contract IDebtIssuanceModule","name":"_issuanceModule","type":"address"},{"internalType":"address","name":"_wrapModule","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"_recipient","type":"address"},{"indexed":true,"internalType":"contract ISetToken","name":"_setToken","type":"address"},{"indexed":true,"internalType":"contract IERC20","name":"_inputToken","type":"address"},{"indexed":false,"internalType":"uint256","name":"_amountInputToken","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"_amountSetIssued","type":"uint256"}],"name":"FlashMint","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"_recipient","type":"address"},{"indexed":true,"internalType":"contract ISetToken","name":"_setToken","type":"address"},{"indexed":true,"internalType":"contract IERC20","name":"_outputToken","type":"address"},{"indexed":false,"internalType":"uint256","name":"_amountSetRedeemed","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"_amountOutputToken","type":"uint256"}],"name":"FlashRedeem","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"previousOwner","type":"address"},{"indexed":true,"internalType":"address","name":"newOwner","type":"address"}],"name":"OwnershipTransferred","type":"event"},{"inputs":[{"internalType":"contract ISetToken","name":"_setToken","type":"address"}],"name":"approveSetToken","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"dexAdapter","outputs":[{"internalType":"address","name":"quickRouter","type":"address"},{"internalType":"address","name":"sushiRouter","type":"address"},{"internalType":"address","name":"uniV3Router","type":"address"},{"internalType":"address","name":"uniV3Quoter","type":"address"},{"internalType":"address","name":"curveAddressProvider","type":"address"},{"internalType":"address","name":"curveCalculator","type":"address"},{"internalType":"address","name":"balV2Vault","type":"address"},{"internalType":"address","name":"weth","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"contract ISetToken","name":"_setToken","type":"address"},{"internalType":"address","name":"_inputToken","type":"address"},{"internalType":"uint256","name":"_setAmount","type":"uint256"},{"components":[{"internalType":"address","name":"underlyingERC20","type":"address"},{"components":[{"internalType":"address[]","name":"path","type":"address[]"},{"internalType":"uint24[]","name":"fees","type":"uint24[]"},{"internalType":"address","name":"pool","type":"address"},{"internalType":"bytes32[]","name":"poolIds","type":"bytes32[]"},{"internalType":"enum DEXAdapterV3.Exchange","name":"exchange","type":"uint8"}],"internalType":"struct DEXAdapterV3.SwapData","name":"dexData","type":"tuple"},{"internalType":"uint256","name":"buyUnderlyingAmount","type":"uint256"}],"internalType":"struct FlashMintWrapped.ComponentSwapData[]","name":"_swapData","type":"tuple[]"}],"name":"getIssueExactSet","outputs":[{"internalType":"uint256","name":"amountInputNeeded","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"contract ISetToken","name":"_setToken","type":"address"},{"internalType":"address","name":"_outputToken","type":"address"},{"internalType":"uint256","name":"_setAmount","type":"uint256"},{"components":[{"internalType":"address","name":"underlyingERC20","type":"address"},{"components":[{"internalType":"address[]","name":"path","type":"address[]"},{"internalType":"uint24[]","name":"fees","type":"uint24[]"},{"internalType":"address","name":"pool","type":"address"},{"internalType":"bytes32[]","name":"poolIds","type":"bytes32[]"},{"internalType":"enum DEXAdapterV3.Exchange","name":"exchange","type":"uint8"}],"internalType":"struct DEXAdapterV3.SwapData","name":"dexData","type":"tuple"},{"internalType":"uint256","name":"buyUnderlyingAmount","type":"uint256"}],"internalType":"struct FlashMintWrapped.ComponentSwapData[]","name":"_swapData","type":"tuple[]"}],"name":"getRedeemExactSet","outputs":[{"internalType":"uint256","name":"amountOutputReceived","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"issuanceModule","outputs":[{"internalType":"contract IDebtIssuanceModule","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"contract ISetToken","name":"_setToken","type":"address"},{"internalType":"contract IERC20","name":"_inputToken","type":"address"},{"internalType":"uint256","name":"_amountSetToken","type":"uint256"},{"internalType":"uint256","name":"_maxAmountInputToken","type":"uint256"},{"components":[{"internalType":"address","name":"underlyingERC20","type":"address"},{"components":[{"internalType":"address[]","name":"path","type":"address[]"},{"internalType":"uint24[]","name":"fees","type":"uint24[]"},{"internalType":"address","name":"pool","type":"address"},{"internalType":"bytes32[]","name":"poolIds","type":"bytes32[]"},{"internalType":"enum DEXAdapterV3.Exchange","name":"exchange","type":"uint8"}],"internalType":"struct DEXAdapterV3.SwapData","name":"dexData","type":"tuple"},{"internalType":"uint256","name":"buyUnderlyingAmount","type":"uint256"}],"internalType":"struct FlashMintWrapped.ComponentSwapData[]","name":"_swapData","type":"tuple[]"},{"components":[{"internalType":"string","name":"integrationName","type":"string"},{"internalType":"bytes","name":"wrapData","type":"bytes"}],"internalType":"struct FlashMintWrapped.ComponentWrapData[]","name":"_wrapData","type":"tuple[]"}],"name":"issueExactSetFromERC20","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"contract ISetToken","name":"_setToken","type":"address"},{"internalType":"uint256","name":"_amountSetToken","type":"uint256"},{"components":[{"internalType":"address","name":"underlyingERC20","type":"address"},{"components":[{"internalType":"address[]","name":"path","type":"address[]"},{"internalType":"uint24[]","name":"fees","type":"uint24[]"},{"internalType":"address","name":"pool","type":"address"},{"internalType":"bytes32[]","name":"poolIds","type":"bytes32[]"},{"internalType":"enum DEXAdapterV3.Exchange","name":"exchange","type":"uint8"}],"internalType":"struct DEXAdapterV3.SwapData","name":"dexData","type":"tuple"},{"internalType":"uint256","name":"buyUnderlyingAmount","type":"uint256"}],"internalType":"struct FlashMintWrapped.ComponentSwapData[]","name":"_swapData","type":"tuple[]"},{"components":[{"internalType":"string","name":"integrationName","type":"string"},{"internalType":"bytes","name":"wrapData","type":"bytes"}],"internalType":"struct FlashMintWrapped.ComponentWrapData[]","name":"_wrapData","type":"tuple[]"}],"name":"issueExactSetFromETH","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"payable","type":"function"},{"inputs":[],"name":"owner","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"contract ISetToken","name":"_setToken","type":"address"},{"internalType":"contract IERC20","name":"_outputToken","type":"address"},{"internalType":"uint256","name":"_amountSetToken","type":"uint256"},{"internalType":"uint256","name":"_minOutputReceive","type":"uint256"},{"components":[{"internalType":"address","name":"underlyingERC20","type":"address"},{"components":[{"internalType":"address[]","name":"path","type":"address[]"},{"internalType":"uint24[]","name":"fees","type":"uint24[]"},{"internalType":"address","name":"pool","type":"address"},{"internalType":"bytes32[]","name":"poolIds","type":"bytes32[]"},{"internalType":"enum DEXAdapterV3.Exchange","name":"exchange","type":"uint8"}],"internalType":"struct DEXAdapterV3.SwapData","name":"dexData","type":"tuple"},{"internalType":"uint256","name":"buyUnderlyingAmount","type":"uint256"}],"internalType":"struct FlashMintWrapped.ComponentSwapData[]","name":"_swapData","type":"tuple[]"},{"components":[{"internalType":"string","name":"integrationName","type":"string"},{"internalType":"bytes","name":"wrapData","type":"bytes"}],"internalType":"struct FlashMintWrapped.ComponentWrapData[]","name":"_unwrapData","type":"tuple[]"}],"name":"redeemExactSetForERC20","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"contract ISetToken","name":"_setToken","type":"address"},{"internalType":"uint256","name":"_amountSetToken","type":"uint256"},{"internalType":"uint256","name":"_minOutputReceive","type":"uint256"},{"components":[{"internalType":"address","name":"underlyingERC20","type":"address"},{"components":[{"internalType":"address[]","name":"path","type":"address[]"},{"internalType":"uint24[]","name":"fees","type":"uint24[]"},{"internalType":"address","name":"pool","type":"address"},{"internalType":"bytes32[]","name":"poolIds","type":"bytes32[]"},{"internalType":"enum DEXAdapterV3.Exchange","name":"exchange","type":"uint8"}],"internalType":"struct DEXAdapterV3.SwapData","name":"dexData","type":"tuple"},{"internalType":"uint256","name":"buyUnderlyingAmount","type":"uint256"}],"internalType":"struct FlashMintWrapped.ComponentSwapData[]","name":"_swapData","type":"tuple[]"},{"components":[{"internalType":"string","name":"integrationName","type":"string"},{"internalType":"bytes","name":"wrapData","type":"bytes"}],"internalType":"struct FlashMintWrapped.ComponentWrapData[]","name":"_unwrapData","type":"tuple[]"}],"name":"redeemExactSetForETH","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"renounceOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"setController","outputs":[{"internalType":"contract IController","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"newOwner","type":"address"}],"name":"transferOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"contract IERC20[]","name":"_tokens","type":"address[]"},{"internalType":"address payable","name":"_to","type":"address"}],"name":"withdrawTokens","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[],"name":"wrapModule","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"stateMutability":"payable","type":"receive"}] \ No newline at end of file diff --git a/src/constants/contracts.ts b/src/constants/contracts.ts index c5d6a6b0..73a9c38a 100644 --- a/src/constants/contracts.ts +++ b/src/constants/contracts.ts @@ -3,7 +3,9 @@ import { ChainId } from 'constants/chains' // eslint-disable-next-line @typescript-eslint/no-explicit-any export const Contracts: { [key: number]: any } = { [ChainId.Mainnet]: { + DebtIssuanceModuleV3: '0x86B7C605C03B9bbb0F6A25FBBb63baF15d875193', FlashMintHyEthV3: '0xCb1eEA349f25288627f008C5e2a69b684bddDf49', + FlashMintWrapped: '0x7ddE626dE8CE73229838B5c2F9A71bc7ac207801', }, [ChainId.Arbitrum]: { DebtIssuanceModuleV3: '0x4ac26c26116fa976352b70700af58bc2442489d8', diff --git a/src/constants/swapdata.ts b/src/constants/swapdata.ts index f8c003ad..66c6fac7 100644 --- a/src/constants/swapdata.ts +++ b/src/constants/swapdata.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { Exchange } from 'utils' import { ETH, InterestCompoundingETHIndex, stETH } from './tokens' diff --git a/src/constants/tokens.ts b/src/constants/tokens.ts index 5bcd85ae..bc5b567f 100644 --- a/src/constants/tokens.ts +++ b/src/constants/tokens.ts @@ -1,3 +1,6 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { getIndexTokenData } from '@indexcoop/tokenlists' + export interface Token { symbol: string address?: string @@ -100,6 +103,10 @@ export const RealWorldAssetIndex: Token = { symbol: 'RWA', } +export const TheUSDCYieldIndex: Token = { + ...getIndexTokenData('icUSD', 1)!, +} + // Other export const DAI: Token = { diff --git a/src/flashmint/builders/index.ts b/src/flashmint/builders/index.ts index d5b66f0f..03c3132b 100644 --- a/src/flashmint/builders/index.ts +++ b/src/flashmint/builders/index.ts @@ -2,4 +2,5 @@ export * from './hyeth' export * from './interface' export * from './leveraged' export * from './leveraged-extended' +export * from './wrapped' export * from './zeroex' diff --git a/src/flashmint/builders/wrapped.test.ts b/src/flashmint/builders/wrapped.test.ts new file mode 100644 index 00000000..be0e2ed3 --- /dev/null +++ b/src/flashmint/builders/wrapped.test.ts @@ -0,0 +1,274 @@ +import { BigNumber } from '@ethersproject/bignumber' + +import { ChainId } from 'constants/chains' +import { Contracts } from 'constants/contracts' +import { LocalhostProviderUrl, QuoteTokens } from 'tests/utils' +import { getFlashMintWrappedContract } from 'utils/contracts' +import { wei } from 'utils/numbers' +import { getRpcProvider } from 'utils/rpc-provider' +import { ComponentWrapData } from 'utils/wrap-data' + +import { + FlashMintWrappedBuildRequest, + WrappedTransactionBuilder, +} from './wrapped' + +const rpcUrl = LocalhostProviderUrl +const ZERO_BYTES = '0x0000000000000000000000000000000000000000' + +const FlashMintWrappedAddress = Contracts[ChainId.Mainnet].FlashMintWrapped +const eth = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' +const indexToken = QuoteTokens.icusd.address +const usdcAddress = QuoteTokens.usdc.address + +describe('WrappedTransactionBuilder()', () => { + test('returns null for invalid request (no index token)', async () => { + const buildRequest = getDefaultBuildRequest() + buildRequest.indexToken = '' + const builder = new WrappedTransactionBuilder(rpcUrl) + const tx = await builder.build(buildRequest) + expect(tx).toBeNull() + }) + + test('returns null for invalid request (no input/output token)', async () => { + const buildRequest = getDefaultBuildRequest() + buildRequest.inputOutputToken = '' + const builder = new WrappedTransactionBuilder(rpcUrl) + const tx = await builder.build(buildRequest) + expect(tx).toBeNull() + }) + + test('returns null for invalid request (indexTokenAmount = 0)', async () => { + const buildRequest = getDefaultBuildRequest() + buildRequest.indexTokenAmount = BigNumber.from(0) + const builder = new WrappedTransactionBuilder(rpcUrl) + const tx = await builder.build(buildRequest) + expect(tx).toBeNull() + }) + + test('returns null for invalid request (inputOutputTokenAmount = 0)', async () => { + const buildRequest = getDefaultBuildRequest() + buildRequest.inputOutputTokenAmount = BigNumber.from(0) + const builder = new WrappedTransactionBuilder(rpcUrl) + const tx = await builder.build(buildRequest) + expect(tx).toBeNull() + }) + + test('returns null for invalid request (no component swap data)', async () => { + const buildRequest = getDefaultBuildRequest() + buildRequest.componentSwapData = [] + const builder = new WrappedTransactionBuilder(rpcUrl) + const tx = await builder.build(buildRequest) + expect(tx).toBeNull() + }) + + test('returns null for invalid request (no wrap data)', async () => { + const buildRequest = getDefaultBuildRequest() + buildRequest.componentWrapData = [] + const builder = new WrappedTransactionBuilder(rpcUrl) + const tx = await builder.build(buildRequest) + expect(tx).toBeNull() + }) + + test('returns null for invalid request (wrap data and swap data length mismatch)', async () => { + const buildRequest = getDefaultBuildRequest() + buildRequest.componentWrapData = buildRequest.componentWrapData.slice(0, -1) + const builder = new WrappedTransactionBuilder(rpcUrl) + const tx = await builder.build(buildRequest) + expect(tx).toBeNull() + }) + + test('returns a tx for icUSD MMI (ERC20)', async () => { + const buildRequest = getDefaultBuildRequest() + const provider = getRpcProvider(rpcUrl) + const contract = getFlashMintWrappedContract(provider) + const refTx = await contract.populateTransaction.issueExactSetFromERC20( + buildRequest.indexToken, + buildRequest.inputOutputToken, + buildRequest.indexTokenAmount, + buildRequest.inputOutputTokenAmount, + buildRequest.componentSwapData, + buildRequest.componentWrapData + ) + const builder = new WrappedTransactionBuilder(rpcUrl) + const tx = await builder.build(buildRequest) + if (!tx) fail() + expect(tx.to).toBe(FlashMintWrappedAddress) + expect(tx.data).toEqual(refTx.data) + }) + + test('returns a tx for minting icUSD (ETH)', async () => { + const buildRequest = getDefaultBuildRequest(true, eth, 'ETH') + const provider = getRpcProvider(rpcUrl) + const contract = getFlashMintWrappedContract(provider) + const refTx = await contract.populateTransaction.issueExactSetFromETH( + buildRequest.indexToken, + buildRequest.indexTokenAmount, + buildRequest.componentSwapData, + buildRequest.componentWrapData, + { value: buildRequest.inputOutputTokenAmount } + ) + const builder = new WrappedTransactionBuilder(rpcUrl) + const tx = await builder.build(buildRequest) + if (!tx) fail() + expect(tx.to).toBe(FlashMintWrappedAddress) + expect(tx.data).toEqual(refTx.data) + expect(tx.value).toEqual(buildRequest.inputOutputTokenAmount) + }) + + test('returns a tx for redeeming icUSD (ERC20)', async () => { + const buildRequest = getDefaultBuildRequest(false) + const provider = getRpcProvider(rpcUrl) + const contract = getFlashMintWrappedContract(provider) + const refTx = await contract.populateTransaction.redeemExactSetForERC20( + buildRequest.indexToken, + buildRequest.inputOutputToken, + buildRequest.indexTokenAmount, + buildRequest.inputOutputTokenAmount, + buildRequest.componentSwapData, + buildRequest.componentWrapData + ) + const builder = new WrappedTransactionBuilder(rpcUrl) + const tx = await builder.build(buildRequest) + if (!tx) fail() + expect(tx.to).toBe(FlashMintWrappedAddress) + expect(tx.data).toEqual(refTx.data) + }) + + test('returns a tx for redeeming icUSD (ETH)', async () => { + const buildRequest = getDefaultBuildRequest(false, eth, 'ETH') + const provider = getRpcProvider(rpcUrl) + const contract = getFlashMintWrappedContract(provider) + const refTx = await contract.populateTransaction.redeemExactSetForETH( + buildRequest.indexToken, + buildRequest.indexTokenAmount, + buildRequest.inputOutputTokenAmount, + buildRequest.componentSwapData, + buildRequest.componentWrapData + ) + const builder = new WrappedTransactionBuilder(rpcUrl) + const tx = await builder.build(buildRequest) + if (!tx) fail() + expect(tx.to).toBe(FlashMintWrappedAddress) + expect(tx.data).toEqual(refTx.data) + }) +}) + +function getDefaultBuildRequest( + isMinting = true, + inputOutputToken: string = usdcAddress, + inputOutputTokenSymbol = 'USDC' +): FlashMintWrappedBuildRequest { + const wrapData: ComponentWrapData = { + integrationName: '', + wrapData: ZERO_BYTES, + } + return { + isMinting, + indexToken, + inputOutputToken, + indexTokenAmount: wei(1), + inputOutputTokenAmount: BigNumber.from('16583822409709138541'), + inputOutputTokenSymbol, + componentSwapData: [ + { + underlyingERC20: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + buyUnderlyingAmount: BigNumber.from('16666666666666666666'), + dexData: { + exchange: 3, + path: [ + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ], + fees: [3000, 3000], + pool: '0x0000000000000000000000000000000000000000', + poolIds: [], + }, + }, + { + underlyingERC20: '0xdac17f958d2ee523a2206206994597c13d831ec7', + buyUnderlyingAmount: BigNumber.from('16666666666666666666'), + dexData: { + exchange: 3, + path: [ + '0xdac17f958d2ee523a2206206994597c13d831ec7', + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ], + fees: [3000, 3000], + pool: '0x0000000000000000000000000000000000000000', + poolIds: [], + }, + }, + { + underlyingERC20: '0xdac17f958d2ee523a2206206994597c13d831ec7', + buyUnderlyingAmount: BigNumber.from('16666666666666666666'), + dexData: { + exchange: 3, + path: [ + '0xdac17f958d2ee523a2206206994597c13d831ec7', + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ], + fees: [3000, 3000], + pool: '0x0000000000000000000000000000000000000000', + poolIds: [], + }, + }, + { + underlyingERC20: '0x6b175474e89094c44da98b954eedeac495271d0f', + buyUnderlyingAmount: BigNumber.from('1666666666'), + dexData: { + exchange: 3, + path: [ + '0x6b175474e89094c44da98b954eedeac495271d0f', + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ], + fees: [3000, 3000], + pool: '0x0000000000000000000000000000000000000000', + poolIds: [], + }, + }, + { + underlyingERC20: '0x6b175474e89094c44da98b954eedeac495271d0f', + buyUnderlyingAmount: BigNumber.from('1666666666'), + dexData: { + exchange: 3, + path: [ + '0x6b175474e89094c44da98b954eedeac495271d0f', + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ], + fees: [3000, 3000], + pool: '0x0000000000000000000000000000000000000000', + poolIds: [], + }, + }, + { + underlyingERC20: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + buyUnderlyingAmount: BigNumber.from('1666666666'), + dexData: { + exchange: 3, + path: [ + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ], + fees: [3000, 3000], + pool: '0x0000000000000000000000000000000000000000', + poolIds: [], + }, + }, + ], + componentWrapData: [ + wrapData, + wrapData, + wrapData, + wrapData, + wrapData, + wrapData, + ], + } +} diff --git a/src/flashmint/builders/wrapped.ts b/src/flashmint/builders/wrapped.ts new file mode 100644 index 00000000..1196303d --- /dev/null +++ b/src/flashmint/builders/wrapped.ts @@ -0,0 +1,109 @@ +import { TransactionRequest } from '@ethersproject/abstract-provider' +import { BigNumber } from '@ethersproject/bignumber' +import { PopulatedTransaction } from '@ethersproject/contracts' + +import { ComponentSwapData } from 'utils/component-swap-data' +import { getFlashMintWrappedContract } from 'utils/contracts' +import { getRpcProvider } from 'utils/rpc-provider' +import { ComponentWrapData } from 'utils/wrap-data' + +import { TransactionBuilder } from './interface' +import { isEmptyString, isInvalidAmount } from './utils' + +export interface FlashMintWrappedBuildRequest { + isMinting: boolean + indexToken: string + inputOutputToken: string + inputOutputTokenSymbol: string + indexTokenAmount: BigNumber + inputOutputTokenAmount: BigNumber + componentSwapData: ComponentSwapData[] + componentWrapData: ComponentWrapData[] +} + +export class WrappedTransactionBuilder + implements + TransactionBuilder +{ + constructor(private readonly rpcUrl: string) {} + + async build( + request: FlashMintWrappedBuildRequest + ): Promise { + const isValidRequest = this.isValidRequest(request) + if (!isValidRequest) return null + const provider = getRpcProvider(this.rpcUrl) + const { + componentSwapData, + componentWrapData, + indexToken, + indexTokenAmount, + inputOutputToken, + inputOutputTokenSymbol, + inputOutputTokenAmount, + isMinting, + } = request + const inputOutputTokenIsEth = inputOutputTokenSymbol === 'ETH' + const contract = getFlashMintWrappedContract(provider) + let tx: PopulatedTransaction | null = null + if (isMinting) { + if (inputOutputTokenIsEth) { + tx = await contract.populateTransaction.issueExactSetFromETH( + indexToken, + indexTokenAmount, + componentSwapData, + componentWrapData, + { value: inputOutputTokenAmount } + ) + } else { + tx = await contract.populateTransaction.issueExactSetFromERC20( + indexToken, + inputOutputToken, + indexTokenAmount, + inputOutputTokenAmount, // _maxAmountInputToken + componentSwapData, + componentWrapData + ) + } + } else { + if (inputOutputTokenIsEth) { + tx = await contract.populateTransaction.redeemExactSetForETH( + indexToken, + indexTokenAmount, + inputOutputTokenAmount, // _minOutputReceive + componentSwapData, + componentWrapData + ) + } else { + tx = await contract.populateTransaction.redeemExactSetForERC20( + indexToken, + inputOutputToken, + indexTokenAmount, + inputOutputTokenAmount, // _minOutputReceive + componentSwapData, + componentWrapData + ) + } + } + return tx + } + + private isValidRequest(request: FlashMintWrappedBuildRequest): boolean { + const { + componentSwapData, + componentWrapData, + indexToken, + indexTokenAmount, + inputOutputToken, + inputOutputTokenAmount, + } = request + if (isEmptyString(indexToken)) return false + if (isEmptyString(inputOutputToken)) return false + if (isInvalidAmount(indexTokenAmount)) return false + if (isInvalidAmount(inputOutputTokenAmount)) return false + if (componentSwapData.length === 0) return false + if (componentWrapData.length === 0) return false + if (componentSwapData.length !== componentWrapData.length) return false + return true + } +} diff --git a/src/quote/flashmint/index.ts b/src/quote/flashmint/index.ts index 757ca68e..190b82aa 100644 --- a/src/quote/flashmint/index.ts +++ b/src/quote/flashmint/index.ts @@ -1,4 +1,5 @@ export * from './hyeth' export * from './leveraged' export * from './leveraged-extended' +export * from './wrapped' export * from './zeroEx' diff --git a/src/quote/flashmint/wrapped/index.ts b/src/quote/flashmint/wrapped/index.ts new file mode 100644 index 00000000..6f29423e --- /dev/null +++ b/src/quote/flashmint/wrapped/index.ts @@ -0,0 +1 @@ +export * from './provider' diff --git a/src/quote/flashmint/wrapped/provider.test.ts b/src/quote/flashmint/wrapped/provider.test.ts new file mode 100644 index 00000000..dfee220e --- /dev/null +++ b/src/quote/flashmint/wrapped/provider.test.ts @@ -0,0 +1,91 @@ +import { + IndexZeroExSwapQuoteProvider, + LocalhostProviderUrl, + QuoteTokens, +} from 'tests/utils' +import { wei } from 'utils/numbers' +import { FlashMintWrappedQuoteRequest, WrappedQuoteProvider } from '.' + +const { icusd, usdc, weth } = QuoteTokens +const indexToken = icusd +const chainId = 1 +const provider = LocalhostProviderUrl +const swapQuoteProvider = IndexZeroExSwapQuoteProvider + +describe('WrappedQuoteProvider()', () => { + test('returns a quote for minting icUSD', async () => { + const inputToken = usdc + const request: FlashMintWrappedQuoteRequest = { + chainId, + isMinting: true, + inputToken, + outputToken: indexToken, + indexTokenAmount: wei(1), + slippage: 0.5, + } + const quoteProvider = new WrappedQuoteProvider(provider, swapQuoteProvider) + const quote = await quoteProvider.getQuote(request) + if (!quote) fail() + expect(quote.indexTokenAmount).toEqual(request.indexTokenAmount) + expect(quote.inputOutputTokenAmount.gt(0)).toEqual(true) + expect(quote.componentSwapData.length).toEqual(1) + expect(quote.componentWrapData.length).toEqual(1) + }) + + test('returns a quote for minting icUSD w/ WETH', async () => { + const inputToken = weth + const request: FlashMintWrappedQuoteRequest = { + chainId, + isMinting: true, + inputToken, + outputToken: indexToken, + indexTokenAmount: wei(1), + slippage: 0.5, + } + const quoteProvider = new WrappedQuoteProvider(provider, swapQuoteProvider) + const quote = await quoteProvider.getQuote(request) + if (!quote) fail() + expect(quote.indexTokenAmount).toEqual(request.indexTokenAmount) + expect(quote.inputOutputTokenAmount.gt(0)).toEqual(true) + expect(quote.componentSwapData.length).toEqual(1) + expect(quote.componentWrapData.length).toEqual(1) + }) + + test('returns a quote redeeming icUSD for USDC', async () => { + const outputToken = usdc + const request: FlashMintWrappedQuoteRequest = { + chainId, + isMinting: false, + inputToken: indexToken, + outputToken, + indexTokenAmount: wei(1), + slippage: 0.5, + } + const quoteProvider = new WrappedQuoteProvider(provider, swapQuoteProvider) + const quote = await quoteProvider.getQuote(request) + if (!quote) fail() + expect(quote.indexTokenAmount).toEqual(request.indexTokenAmount) + expect(quote.inputOutputTokenAmount.gt(0)).toEqual(true) + expect(quote.componentSwapData.length).toEqual(1) + expect(quote.componentWrapData.length).toEqual(1) + }) + + test('returns a quote for redeeming icUSD for WETH', async () => { + const outputToken = weth + const request: FlashMintWrappedQuoteRequest = { + chainId, + isMinting: false, + inputToken: indexToken, + outputToken, + indexTokenAmount: wei(1), + slippage: 0.5, + } + const quoteProvider = new WrappedQuoteProvider(provider, swapQuoteProvider) + const quote = await quoteProvider.getQuote(request) + if (!quote) fail() + expect(quote.indexTokenAmount).toEqual(request.indexTokenAmount) + expect(quote.inputOutputTokenAmount.gt(0)).toEqual(true) + expect(quote.componentSwapData.length).toEqual(1) + expect(quote.componentWrapData.length).toEqual(1) + }) +}) diff --git a/src/quote/flashmint/wrapped/provider.ts b/src/quote/flashmint/wrapped/provider.ts new file mode 100644 index 00000000..35d48559 --- /dev/null +++ b/src/quote/flashmint/wrapped/provider.ts @@ -0,0 +1,111 @@ +import { BigNumber } from '@ethersproject/bignumber' + +import { SwapQuoteProvider } from 'quote/swap' +import { + ComponentSwapData, + ComponentWrapData, + getFlashMintWrappedContract, + getIssuanceComponentSwapData, + getRedemptionComponentSwapData, + getWrapData, + slippageAdjustedTokenAmount, +} from 'utils' +import { getRpcProvider } from 'utils/rpc-provider' + +import { QuoteProvider, QuoteToken } from '../../interfaces' + +export interface FlashMintWrappedQuoteRequest { + chainId: number + isMinting: boolean + inputToken: QuoteToken + outputToken: QuoteToken + indexTokenAmount: BigNumber + slippage: number +} + +export interface FlashMintWrappedQuote { + componentSwapData: ComponentSwapData[] + componentWrapData: ComponentWrapData[] + indexTokenAmount: BigNumber + inputOutputTokenAmount: BigNumber +} + +export class WrappedQuoteProvider + implements QuoteProvider +{ + constructor( + private readonly rpcUrl: string, + private readonly swapQuoteProvider: SwapQuoteProvider + ) {} + + async getQuote( + request: FlashMintWrappedQuoteRequest + ): Promise { + const { + chainId, + inputToken, + indexTokenAmount, + isMinting, + outputToken, + slippage, + } = request + const indexToken = isMinting ? outputToken : inputToken + const indexTokenSymbol = indexToken.symbol + const componentSwapData = isMinting + ? await getIssuanceComponentSwapData( + { + chainId, + indexTokenSymbol, + indexToken: indexToken.address, + inputToken: inputToken.address, + indexTokenAmount, + }, + this.rpcUrl, + this.swapQuoteProvider + ) + : await getRedemptionComponentSwapData( + { + chainId, + indexTokenSymbol, + indexToken: indexToken.address, + outputToken: outputToken.address, + indexTokenAmount, + }, + this.rpcUrl, + this.swapQuoteProvider + ) + const componentWrapData = getWrapData(indexToken.symbol) + if (componentSwapData.length !== componentWrapData.length) return null + let estimatedInputOutputAmount: BigNumber = BigNumber.from(0) + const provider = getRpcProvider(this.rpcUrl) + const contract = getFlashMintWrappedContract(provider) + if (isMinting) { + estimatedInputOutputAmount = await contract.callStatic.getIssueExactSet( + indexToken.address, + inputToken.address, + indexTokenAmount, + componentSwapData + ) + } else { + estimatedInputOutputAmount = await contract.callStatic.getRedeemExactSet( + indexToken.address, + outputToken.address, + indexTokenAmount, + componentSwapData + ) + } + const inputOutputTokenAmount = slippageAdjustedTokenAmount( + estimatedInputOutputAmount, + isMinting ? inputToken.decimals : outputToken.decimals, + slippage, + isMinting + ) + const quote: FlashMintWrappedQuote = { + componentSwapData, + componentWrapData, + indexTokenAmount, + inputOutputTokenAmount, + } + return quote + } +} diff --git a/src/quote/provider/index.test.ts b/src/quote/provider/index.test.ts index a5d45e34..7dbbb8cc 100644 --- a/src/quote/provider/index.test.ts +++ b/src/quote/provider/index.test.ts @@ -2,11 +2,7 @@ import { ChainId } from 'constants/chains' import { Contracts } from 'constants/contracts' import { IndexCoopEthereum2xIndex } from 'constants/tokens' -import { - getFlashMintLeveragedContractForToken, - getFlashMintZeroExContractForToken, - wei, -} from 'utils' +import { getFlashMintLeveragedContractForToken, wei } from 'utils' import { IndexZeroExSwapQuoteProvider, @@ -29,7 +25,7 @@ const provider = LocalhostProvider const zeroexSwapQuoteProvider = IndexZeroExSwapQuoteProvider const FlashMintHyEthAddress = Contracts[ChainId.Mainnet].FlashMintHyEthV3 -const { dseth, eth, eth2x, hyeth, iceth, usdc } = QuoteTokens +const { eth, eth2x, hyeth, iceth, icusd, usdc } = QuoteTokens describe('FlashMintQuoteProvider()', () => { test('throws if token is unsupported', async () => { @@ -55,12 +51,18 @@ describe('FlashMintQuoteProvider()', () => { ) }) - test.skip('returns a quote for minting dsETH', async () => { + test('returns a quote for minting ETH2X', async () => { + const arbitrumProvider = LocalhostProviderArbitrum const inputToken = usdc - const outputToken = dseth - const contract = getFlashMintZeroExContractForToken( + const outputToken = { + address: IndexCoopEthereum2xIndex.addressArbitrum!, + decimals: eth2x.decimals, + symbol: eth2x.symbol, + } + const contract = getFlashMintLeveragedContractForToken( outputToken.symbol, - undefined + arbitrumProvider, + ChainId.Arbitrum ) const request: FlashMintQuoteRequest = { isMinting: true, @@ -70,57 +72,20 @@ describe('FlashMintQuoteProvider()', () => { slippage: 0.5, } const quoteProvider = new FlashMintQuoteProvider( - rpcUrl, - zeroexSwapQuoteProvider + LocalhostProviderUrlArbitrum, + IndexZeroExSwapQuoteProviderArbitrum ) const quote = await quoteProvider.getQuote(request) if (!quote) fail() - const chainId = (await provider.getNetwork()).chainId + const chainId = (await arbitrumProvider.getNetwork()).chainId expect(quote.chainId).toEqual(chainId) - expect(quote.contractType).toEqual(FlashMintContractType.zeroEx) + expect(quote.contractType).toEqual(FlashMintContractType.leveragedExtended) expect(quote.contract).toEqual(contract.address) expect(quote.isMinting).toEqual(request.isMinting) expect(quote.inputToken).toEqual(request.inputToken) expect(quote.outputToken).toEqual(request.outputToken) - expect(quote.inputAmount).toEqual(quote.inputOutputAmount) - expect(quote.outputAmount).toEqual(request.indexTokenAmount) - expect(quote.indexTokenAmount).toEqual(request.indexTokenAmount) - expect(quote.inputOutputAmount.gt(0)).toBe(true) - expect(quote.slippage).toEqual(request.slippage) - expect(quote.tx).not.toBeNull() - expect(quote.tx.to).toBe(contract.address) - expect(quote.tx.data?.length).toBeGreaterThan(0) - }) - - test.skip('returns a quote for redeeming dsETH', async () => { - const inputToken = dseth - const outputToken = usdc - const contract = getFlashMintZeroExContractForToken( - inputToken.symbol, - undefined - ) - const request: FlashMintQuoteRequest = { - isMinting: false, - inputToken, - outputToken, - indexTokenAmount: wei(1), - slippage: 0.5, - } - const quoteProvider = new FlashMintQuoteProvider( - rpcUrl, - zeroexSwapQuoteProvider - ) - const quote = await quoteProvider.getQuote(request) - if (!quote) fail() - const chainId = (await provider.getNetwork()).chainId - expect(quote.chainId).toEqual(chainId) - expect(quote.contractType).toEqual(FlashMintContractType.zeroEx) - expect(quote.contract).toEqual(contract.address) - expect(quote.isMinting).toEqual(request.isMinting) - expect(quote.inputToken).toEqual(request.inputToken) expect(quote.outputToken).toEqual(request.outputToken) - expect(quote.inputAmount).toEqual(request.indexTokenAmount) - expect(quote.outputAmount).toEqual(quote.inputOutputAmount) + expect(quote.inputAmount).toEqual(quote.inputOutputAmount) expect(quote.indexTokenAmount).toEqual(request.indexTokenAmount) expect(quote.inputOutputAmount.gt(0)).toBe(true) expect(quote.slippage).toEqual(request.slippage) @@ -129,36 +94,24 @@ describe('FlashMintQuoteProvider()', () => { expect(quote.tx.data?.length).toBeGreaterThan(0) }) - test('returns a quote for minting ETH2X', async () => { - const arbitrumProvider = LocalhostProviderArbitrum - const inputToken = usdc - const outputToken = { - address: IndexCoopEthereum2xIndex.addressArbitrum!, - decimals: eth2x.decimals, - symbol: eth2x.symbol, - } - const contract = getFlashMintLeveragedContractForToken( - outputToken.symbol, - arbitrumProvider, - ChainId.Arbitrum - ) + test('returns a quote for minting hyETH', async () => { const request: FlashMintQuoteRequest = { isMinting: true, - inputToken, - outputToken, + inputToken: usdc, + outputToken: hyeth, indexTokenAmount: wei(1), slippage: 0.5, } const quoteProvider = new FlashMintQuoteProvider( - LocalhostProviderUrlArbitrum, - IndexZeroExSwapQuoteProviderArbitrum + LocalhostProviderUrl, + IndexZeroExSwapQuoteProvider ) const quote = await quoteProvider.getQuote(request) if (!quote) fail() - const chainId = (await arbitrumProvider.getNetwork()).chainId + const chainId = (await provider.getNetwork()).chainId expect(quote.chainId).toEqual(chainId) - expect(quote.contractType).toEqual(FlashMintContractType.leveragedExtended) - expect(quote.contract).toEqual(contract.address) + expect(quote.contractType).toEqual(FlashMintContractType.hyeth) + expect(quote.contract).toEqual(FlashMintHyEthAddress) expect(quote.isMinting).toEqual(request.isMinting) expect(quote.inputToken).toEqual(request.inputToken) expect(quote.outputToken).toEqual(request.outputToken) @@ -168,15 +121,15 @@ describe('FlashMintQuoteProvider()', () => { expect(quote.inputOutputAmount.gt(0)).toBe(true) expect(quote.slippage).toEqual(request.slippage) expect(quote.tx).not.toBeNull() - expect(quote.tx.to).toBe(contract.address) + expect(quote.tx.to).toBe(FlashMintHyEthAddress) expect(quote.tx.data?.length).toBeGreaterThan(0) }) - test('returns a quote for minting hyETH', async () => { + test('returns a quote for minting icUSD', async () => { const request: FlashMintQuoteRequest = { isMinting: true, inputToken: usdc, - outputToken: hyeth, + outputToken: icusd, indexTokenAmount: wei(1), slippage: 0.5, } @@ -186,10 +139,11 @@ describe('FlashMintQuoteProvider()', () => { ) const quote = await quoteProvider.getQuote(request) if (!quote) fail() + const FlashMintWrappedAddress = Contracts[ChainId.Mainnet].FlashMintWrapped const chainId = (await provider.getNetwork()).chainId expect(quote.chainId).toEqual(chainId) - expect(quote.contractType).toEqual(FlashMintContractType.hyeth) - expect(quote.contract).toEqual(FlashMintHyEthAddress) + expect(quote.contractType).toEqual(FlashMintContractType.wrapped) + expect(quote.contract).toEqual(FlashMintWrappedAddress) expect(quote.isMinting).toEqual(request.isMinting) expect(quote.inputToken).toEqual(request.inputToken) expect(quote.outputToken).toEqual(request.outputToken) @@ -199,7 +153,7 @@ describe('FlashMintQuoteProvider()', () => { expect(quote.inputOutputAmount.gt(0)).toBe(true) expect(quote.slippage).toEqual(request.slippage) expect(quote.tx).not.toBeNull() - expect(quote.tx.to).toBe(FlashMintHyEthAddress) + expect(quote.tx.to).toBe(FlashMintWrappedAddress) expect(quote.tx.data?.length).toBeGreaterThan(0) }) @@ -267,8 +221,9 @@ describe('FlashMintQuoteProvider()', () => { expect(quote.isMinting).toEqual(request.isMinting) expect(quote.inputToken).toEqual(request.inputToken) expect(quote.outputToken).toEqual(request.outputToken) - expect(quote.outputToken).toEqual(request.outputToken) - expect(quote.inputAmount).toEqual(quote.indexTokenAmount) + expect(quote.indexTokenAmount).toEqual(request.indexTokenAmount) + expect(quote.inputAmount).toEqual(request.indexTokenAmount) + expect(quote.outputAmount).toEqual(quote.inputOutputAmount) expect(quote.inputOutputAmount.gt(0)).toBe(true) expect(quote.slippage).toEqual(request.slippage) expect(quote.tx).not.toBeNull() @@ -313,4 +268,36 @@ describe('FlashMintQuoteProvider()', () => { expect(quote.tx.to).toBe(contract.address) expect(quote.tx.data?.length).toBeGreaterThan(0) }) + + test('returns a quote for redeeming icUSD', async () => { + const request: FlashMintQuoteRequest = { + isMinting: false, + inputToken: icusd, + outputToken: usdc, + indexTokenAmount: wei(1), + slippage: 0.5, + } + const quoteProvider = new FlashMintQuoteProvider( + LocalhostProviderUrl, + IndexZeroExSwapQuoteProvider + ) + const quote = await quoteProvider.getQuote(request) + if (!quote) fail() + const FlashMintWrappedAddress = Contracts[ChainId.Mainnet].FlashMintWrapped + const chainId = (await provider.getNetwork()).chainId + expect(quote.chainId).toEqual(chainId) + expect(quote.contractType).toEqual(FlashMintContractType.wrapped) + expect(quote.contract).toEqual(FlashMintWrappedAddress) + expect(quote.isMinting).toEqual(request.isMinting) + expect(quote.inputToken).toEqual(request.inputToken) + expect(quote.outputToken).toEqual(request.outputToken) + expect(quote.inputAmount).toEqual(request.indexTokenAmount) + expect(quote.outputAmount).toEqual(quote.inputOutputAmount) + expect(quote.indexTokenAmount).toEqual(request.indexTokenAmount) + expect(quote.inputOutputAmount.gt(0)).toBe(true) + expect(quote.slippage).toEqual(request.slippage) + expect(quote.tx).not.toBeNull() + expect(quote.tx.to).toBe(FlashMintWrappedAddress) + expect(quote.tx.data?.length).toBeGreaterThan(0) + }) }) diff --git a/src/quote/provider/index.ts b/src/quote/provider/index.ts index bb9bf7cf..31f89910 100644 --- a/src/quote/provider/index.ts +++ b/src/quote/provider/index.ts @@ -5,9 +5,11 @@ import { FlashMintHyEthTransactionBuilder, FlashMintLeveragedBuildRequest, FlashMintLeveragedExtendedBuildRequest, + FlashMintWrappedBuildRequest, FlashMintZeroExBuildRequest, LeveragedExtendedTransactionBuilder, LeveragedTransactionBuilder, + WrappedTransactionBuilder, ZeroExTransactionBuilder, } from 'flashmint' import { getRpcProvider } from 'utils/rpc-provider' @@ -16,16 +18,18 @@ import { wei } from 'utils' import { FlashMintHyEthQuoteProvider } from '../flashmint/hyeth' import { LeveragedQuoteProvider } from '../flashmint/leveraged' import { LeveragedExtendedQuoteProvider } from '../flashmint/leveraged-extended' +import { WrappedQuoteProvider } from '../flashmint/wrapped' import { ZeroExQuoteProvider } from '../flashmint/zeroEx' import { QuoteProvider, QuoteToken } from '../interfaces' import { SwapQuoteProvider } from '../swap' -import { getContractType } from './utils' +import { buildQuoteResponse, getContractType } from './utils' export enum FlashMintContractType { hyeth, leveraged, leveragedExtended, + wrapped, zeroEx, } @@ -112,21 +116,13 @@ export class FlashMintQuoteProvider } const tx = await builder.build(txRequest) if (!tx) return null - return { + return buildQuoteResponse( + request, chainId, contractType, - /* eslint-disable @typescript-eslint/no-non-null-assertion */ - contract: tx.to!, - isMinting, - inputToken, - outputToken, - inputAmount: isMinting ? inputOutputTokenAmount : indexTokenAmount, - outputAmount: isMinting ? indexTokenAmount : inputOutputTokenAmount, - indexTokenAmount, - inputOutputAmount: inputOutputTokenAmount, - slippage, - tx, - } + inputOutputTokenAmount, + tx + ) } case FlashMintContractType.leveraged: { const leveragedQuoteProvider = new LeveragedQuoteProvider( @@ -149,22 +145,13 @@ export class FlashMintQuoteProvider } const tx = await builder.build(txRequest) if (!tx) return null - const { inputOutputTokenAmount } = leveragedQuote - return { + return buildQuoteResponse( + request, chainId, contractType, - /* eslint-disable @typescript-eslint/no-non-null-assertion */ - contract: tx.to!, - isMinting, - inputToken, - outputToken, - inputAmount: isMinting ? inputOutputTokenAmount : indexTokenAmount, - outputAmount: isMinting ? indexTokenAmount : inputOutputTokenAmount, - indexTokenAmount, - inputOutputAmount: inputOutputTokenAmount, - slippage, - tx, - } + leveragedQuote.inputOutputTokenAmount, + tx + ) } case FlashMintContractType.leveragedExtended: { const leverageExtendedQuoteProvider = @@ -190,22 +177,44 @@ export class FlashMintQuoteProvider } const tx = await builder.build(txRequest) if (!tx) return null - const { inputOutputTokenAmount } = leveragedExtendedQuote - return { + return buildQuoteResponse( + request, chainId, contractType, - /* eslint-disable @typescript-eslint/no-non-null-assertion */ - contract: tx.to!, + leveragedExtendedQuote.inputOutputTokenAmount, + tx + ) + } + case FlashMintContractType.wrapped: { + const wrappedQuoteProvider = new WrappedQuoteProvider( + rpcUrl, + swapQuoteProvider + ) + const wrappedQuote = await wrappedQuoteProvider.getQuote({ + ...request, + chainId, + }) + if (!wrappedQuote) return null + const builder = new WrappedTransactionBuilder(rpcUrl) + const txRequest: FlashMintWrappedBuildRequest = { isMinting, - inputToken, - outputToken, - inputAmount: isMinting ? inputOutputTokenAmount : indexTokenAmount, - outputAmount: isMinting ? indexTokenAmount : inputOutputTokenAmount, + indexToken: indexToken.address, indexTokenAmount, - inputOutputAmount: inputOutputTokenAmount, - slippage, - tx, + inputOutputToken: inputOutputToken.address, + inputOutputTokenSymbol: inputOutputToken.symbol, + inputOutputTokenAmount: wrappedQuote.inputOutputTokenAmount, + componentSwapData: wrappedQuote.componentSwapData, + componentWrapData: wrappedQuote.componentWrapData, } + const tx = await builder.build(txRequest) + if (!tx) return null + return buildQuoteResponse( + request, + chainId, + contractType, + wrappedQuote.inputOutputTokenAmount, + tx + ) } case FlashMintContractType.zeroEx: { const zeroExQuoteProvider = new ZeroExQuoteProvider( @@ -227,22 +236,13 @@ export class FlashMintQuoteProvider } const tx = await builder.build(txRequest) if (!tx) return null - const { inputOutputTokenAmount } = zeroExQuote - return { + return buildQuoteResponse( + request, chainId, contractType, - /* eslint-disable @typescript-eslint/no-non-null-assertion */ - contract: tx.to!, - isMinting, - inputToken, - outputToken, - inputAmount: isMinting ? inputOutputTokenAmount : indexTokenAmount, - outputAmount: isMinting ? indexTokenAmount : inputOutputTokenAmount, - indexTokenAmount, - inputOutputAmount: inputOutputTokenAmount, - slippage, - tx, - } + zeroExQuote.inputOutputTokenAmount, + tx + ) } default: return null diff --git a/src/quote/provider/utils.test.ts b/src/quote/provider/utils.test.ts index 73c8da13..14ceeafb 100644 --- a/src/quote/provider/utils.test.ts +++ b/src/quote/provider/utils.test.ts @@ -1,4 +1,5 @@ import { ChainId } from 'constants/chains' +import { Contracts } from 'constants/contracts' import { BanklessBEDIndex, DefiPulseIndex, @@ -14,10 +15,53 @@ import { IndexCoopInverseEthereumIndex, InterestCompoundingETHIndex, MetaverseIndex, + TheUSDCYieldIndex, } from 'constants/tokens' +import { QuoteTokens } from 'tests/utils' +import { wei } from 'utils' -import { FlashMintContractType } from './' -import { getContractType } from './utils' +import { FlashMintContractType, FlashMintQuoteRequest } from './' +import { buildQuoteResponse, getContractType } from './utils' + +const { icusd, usdc } = QuoteTokens + +describe('buildQuoteResponse()', () => { + test('returns correct quote response object', async () => { + const request: FlashMintQuoteRequest = { + isMinting: true, + inputToken: usdc, + outputToken: icusd, + indexTokenAmount: wei(1), + slippage: 0.1, + } + const quoteAmount = wei(100, 6) + const tx = { + to: Contracts[ChainId.Mainnet].FlashMintWrapped, + value: wei(1), + } + const response = buildQuoteResponse( + request, + 1, + FlashMintContractType.wrapped, + quoteAmount, + tx + ) + expect(response).toEqual({ + chainId: 1, + contractType: FlashMintContractType.wrapped, + contract: Contracts[ChainId.Mainnet].FlashMintWrapped, + isMinting: true, + inputToken: usdc, + outputToken: icusd, + inputAmount: quoteAmount, + outputAmount: request.indexTokenAmount, + indexTokenAmount: request.indexTokenAmount, + inputOutputAmount: quoteAmount, + slippage: 0.1, + tx, + }) + }) +}) describe('getContractType()', () => { test('returns correct contract type for leveraged arbitrum tokens', async () => { @@ -126,4 +170,12 @@ describe('getContractType()', () => { ) expect(contractType).toBe(FlashMintContractType.leveraged) }) + + test('returns correct contract type for icUSD', async () => { + const contractType = getContractType( + TheUSDCYieldIndex.symbol, + ChainId.Mainnet + ) + expect(contractType).toBe(FlashMintContractType.wrapped) + }) }) diff --git a/src/quote/provider/utils.ts b/src/quote/provider/utils.ts index 0c63edde..15b72d9f 100644 --- a/src/quote/provider/utils.ts +++ b/src/quote/provider/utils.ts @@ -1,3 +1,6 @@ +import { BigNumber } from '@ethersproject/bignumber' +import { TransactionRequest } from '@ethersproject/abstract-provider' + import { ChainId } from 'constants/chains' import { BanklessBEDIndex, @@ -17,9 +20,40 @@ import { InterestCompoundingETHIndex, MetaverseIndex, RealWorldAssetIndex, + TheUSDCYieldIndex, } from 'constants/tokens' -import { FlashMintContractType } from './index' +import { + FlashMintContractType, + FlashMintQuote, + FlashMintQuoteRequest, +} from './index' + +export function buildQuoteResponse( + request: FlashMintQuoteRequest, + chainId: number, + contractType: FlashMintContractType, + inputOutputTokenAmount: BigNumber, // quote amount + tx: TransactionRequest +): FlashMintQuote { + const { isMinting, indexTokenAmount, inputToken, outputToken, slippage } = + request + return { + chainId, + contractType, + /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ + contract: tx.to!, + isMinting, + inputToken, + outputToken, + inputAmount: isMinting ? inputOutputTokenAmount : indexTokenAmount, + outputAmount: isMinting ? indexTokenAmount : inputOutputTokenAmount, + indexTokenAmount, + inputOutputAmount: inputOutputTokenAmount, + slippage, + tx, + } +} // Returns contract type for token or null if not supported export function getContractType( @@ -47,6 +81,9 @@ export function getContractType( if (token === HighYieldETHIndex.symbol) { return FlashMintContractType.hyeth } + if (token === TheUSDCYieldIndex.symbol) { + return FlashMintContractType.wrapped + } if ( token === BanklessBEDIndex.symbol || token === CoinDeskEthTrendIndex.symbol || diff --git a/src/quote/swap/adapters/uniswap/index.ts b/src/quote/swap/adapters/uniswap/index.ts index 5833ef9d..fd924552 100644 --- a/src/quote/swap/adapters/uniswap/index.ts +++ b/src/quote/swap/adapters/uniswap/index.ts @@ -35,7 +35,7 @@ export class UniswapSwapQuoteProvider implements SwapQuoteProvider { isSameAddress(inputToken, EthAddress) || isSameAddress(outputToken, EthAddress) ) { - // FIXME: remove for production, just for runnint tests and catching any of these cases + // FIXME: remove for production, just for running tests and catching any of these cases throw new Error('Error - using ETH instead of WETH') } diff --git a/src/tests/hyeth.test.ts b/src/tests/hyeth.test.ts index c00cb5e8..4274848b 100644 --- a/src/tests/hyeth.test.ts +++ b/src/tests/hyeth.test.ts @@ -77,7 +77,7 @@ describe('hyETH', () => { await factory.executeTx() }) - test('can mint with ETH (large amout)', async () => { + test.skip('can mint with ETH (large amout)', async () => { await factory.fetchQuote({ isMinting: true, inputToken: eth, diff --git a/src/tests/icusd.test.ts b/src/tests/icusd.test.ts new file mode 100644 index 00000000..dd029f8a --- /dev/null +++ b/src/tests/icusd.test.ts @@ -0,0 +1,49 @@ +import { + getMainnetTestFactory, + QuoteTokens, + SignerAccount4, + TestFactory, + transferFromWhale, + wei, +} from './utils' + +const { icusd, usdc } = QuoteTokens + +describe('icUSD (mainnet)', () => { + const indexToken = icusd + const signer = SignerAccount4 + let factory: TestFactory + beforeEach(async () => { + factory = getMainnetTestFactory(signer) + }) + + test('can mint with USDC', async () => { + const quote = await factory.fetchQuote({ + isMinting: true, + inputToken: usdc, + outputToken: indexToken, + indexTokenAmount: wei('1'), + slippage: 0.5, + }) + const usdcWhale = '0x7713974908Be4BEd47172370115e8b1219F4A5f0' + await transferFromWhale( + usdcWhale, + factory.getSigner().address, + wei('100000', quote.inputToken.decimals), + quote.inputToken.address, + factory.getProvider() + ) + await factory.executeTx() + }) + + test('can redeem to USDC', async () => { + await factory.fetchQuote({ + isMinting: false, + inputToken: indexToken, + outputToken: usdc, + indexTokenAmount: wei('1'), + slippage: 0.5, + }) + await factory.executeTx() + }) +}) diff --git a/src/tests/utils/quoteTokens.ts b/src/tests/utils/quoteTokens.ts index 94b9795a..2addc1cc 100644 --- a/src/tests/utils/quoteTokens.ts +++ b/src/tests/utils/quoteTokens.ts @@ -11,6 +11,7 @@ import { InterestCompoundingETHIndex, MetaverseIndex, RETH, + TheUSDCYieldIndex, USDC, USDT, WETH, @@ -74,6 +75,12 @@ const iceth: QuoteToken = { address: InterestCompoundingETHIndex.address!, } +const icusd: QuoteToken = { + address: TheUSDCYieldIndex.address!, + decimals: 18, + symbol: TheUSDCYieldIndex.symbol, +} + const mvi: QuoteToken = { address: MetaverseIndex.address!, decimals: 18, @@ -132,6 +139,7 @@ export const QuoteTokens = { gtcETH, hyeth, iceth, + icusd, mvi, reth, seth2, diff --git a/src/utils/clients.ts b/src/utils/clients.ts new file mode 100644 index 00000000..00b02012 --- /dev/null +++ b/src/utils/clients.ts @@ -0,0 +1,12 @@ +import { createPublicClient, http } from 'viem' +import { mainnet } from 'viem/chains' + +import { ChainId } from 'constants/chains' + +export function createClient(chainId: number) { + if (chainId !== ChainId.Mainnet) return null + return createPublicClient({ + chain: mainnet, + transport: http(process.env.MAINNET_ALCHEMY_API), + }) +} diff --git a/src/utils/component-swap-data.test.ts b/src/utils/component-swap-data.test.ts new file mode 100644 index 00000000..5a2168db --- /dev/null +++ b/src/utils/component-swap-data.test.ts @@ -0,0 +1,150 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { AddressZero } from 'constants/addresses' +import { TheUSDCYieldIndex, USDC, WETH } from 'constants/tokens' +import { + getIssuanceComponentSwapData, + getRedemptionComponentSwapData, +} from 'utils/component-swap-data' +import { wei } from 'utils/numbers' +import { IndexZeroExSwapQuoteProvider, LocalhostProviderUrl } from 'tests/utils' +import { isSameAddress } from 'utils/addresses' +import { Exchange } from 'utils/swap-data' + +const chainId = 1 +const rpcUrl = LocalhostProviderUrl +const swapQuoteProvider = IndexZeroExSwapQuoteProvider + +const indexTokenSymbol = TheUSDCYieldIndex.symbol +const indexToken = TheUSDCYieldIndex.address! +const usdc = USDC.address! +const weth = WETH.address! + +describe('getIssuanceComponentSwapData()', () => { + test('returns correct swap data based on input token USDC', async () => { + const componentSwapData = await getIssuanceComponentSwapData( + { + chainId, + indexTokenSymbol, + indexToken, + inputToken: usdc, + indexTokenAmount: wei(1), + }, + rpcUrl, + swapQuoteProvider + ) + // TODO: update once rebalanced into components + expect(componentSwapData.length).toBe(1) + for (let i = 0; i < componentSwapData.length; i++) { + expect(isSameAddress(componentSwapData[i].underlyingERC20, usdc)).toBe( + true + ) + // Should be empty as input token is equal to output token (underlying erc20) + const dexData = componentSwapData[i].dexData + expect(dexData.exchange).toEqual(Exchange.None) + expect(dexData.fees).toEqual([]) + expect(dexData.path).toEqual([]) + expect(dexData.pool).toEqual(AddressZero) + expect(dexData.poolIds).toEqual([]) + } + // TODO: update once rebalanced into components + expect(componentSwapData[0].buyUnderlyingAmount.toString()).toBe('1000010') + }) + + test('returns correct swap data based when input token is WETH', async () => { + const componentSwapData = await getIssuanceComponentSwapData( + { + chainId, + indexTokenSymbol, + indexToken, + inputToken: weth, + indexTokenAmount: wei(1), + }, + rpcUrl, + swapQuoteProvider + ) + // TODO: update once rebalanced into components + expect(componentSwapData.length).toBe(1) + for (let i = 0; i < componentSwapData.length; i++) { + expect(isSameAddress(componentSwapData[i].underlyingERC20, usdc)).toBe( + true + ) + // Should be empty as input token is equal to output token (underlying erc20) + const dexData = componentSwapData[i].dexData + expect(dexData.exchange).toEqual(Exchange.UniV3) + expect(dexData.fees.length).toBeGreaterThan(0) + expect(dexData.path).toEqual([ + '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + ]) + expect(dexData.pool).toEqual(AddressZero) + expect(dexData.poolIds).toEqual([]) + } + // TODO: update once rebalanced into components + expect(componentSwapData[0].buyUnderlyingAmount.toString()).toBe('1000010') + }) +}) + +describe('getRedemptionComponentSwapData()', () => { + test('returns correct swap data based for output token USDC', async () => { + const componentSwapData = await getRedemptionComponentSwapData( + { + chainId, + indexTokenSymbol, + indexToken, + outputToken: usdc, + indexTokenAmount: wei(1), + }, + rpcUrl, + swapQuoteProvider + ) + // TODO: update once rebalanced into components + expect(componentSwapData.length).toBe(1) + for (let i = 0; i < componentSwapData.length; i++) { + expect(isSameAddress(componentSwapData[i].underlyingERC20, usdc)).toBe( + true + ) + // Should be empty as input token is equal to output token (underlying erc20) + const dexData = componentSwapData[i].dexData + expect(dexData.exchange).toEqual(Exchange.None) + expect(dexData.fees).toEqual([]) + expect(dexData.path).toEqual([]) + expect(dexData.pool).toEqual(AddressZero) + expect(dexData.poolIds).toEqual([]) + } + // TODO: update once rebalanced into components + expect(componentSwapData[0].buyUnderlyingAmount.toString()).toBe('999990') + }) + + test('returns correct swap data when output token is WETH', async () => { + const componentSwapData = await getRedemptionComponentSwapData( + { + chainId, + indexTokenSymbol, + indexToken, + outputToken: weth, + indexTokenAmount: wei(1), + }, + rpcUrl, + swapQuoteProvider + ) + // TODO: update once rebalanced into components + expect(componentSwapData.length).toBe(1) + for (let i = 0; i < componentSwapData.length; i++) { + expect(isSameAddress(componentSwapData[i].underlyingERC20, usdc)).toBe( + true + ) + // Should be empty as input token is equal to output token (underlying erc20) + const dexData = componentSwapData[i].dexData + expect(dexData.exchange).toEqual(Exchange.UniV3) + expect(dexData.fees.length).toBeGreaterThan(0) + expect(dexData.path).toEqual([ + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + ]) + expect(dexData.pool).toEqual(AddressZero) + expect(dexData.poolIds).toEqual([]) + } + // TODO: update once rebalanced into components + expect(componentSwapData[0].buyUnderlyingAmount.toString()).toBe('999990') + }) +}) diff --git a/src/utils/component-swap-data.ts b/src/utils/component-swap-data.ts new file mode 100644 index 00000000..304dc5ac --- /dev/null +++ b/src/utils/component-swap-data.ts @@ -0,0 +1,286 @@ +import { BigNumber } from '@ethersproject/bignumber' +import { Contract } from '@ethersproject/contracts' +import { Address, parseAbi } from 'viem' + +import { AddressZero } from 'constants/addresses' +import { USDC } from 'constants/tokens' +import { SwapQuote, SwapQuoteProvider } from 'quote' +import { isSameAddress } from 'utils/addresses' +import { createClient } from 'utils/clients' +import { getIssuanceModule } from 'utils/issuanceModules' +import { getRpcProvider } from 'utils/rpc-provider' +import { Exchange, SwapDataV3 } from 'utils/swap-data' + +// const DEFAULT_SLIPPAGE = 0.0015 + +const emptySwapData: SwapDataV3 = { + exchange: Exchange.None, + path: [], + fees: [], + pool: AddressZero, + poolIds: [], +} + +export interface ComponentSwapData { + underlyingERC20: string + dexData: SwapDataV3 + // ONLY relevant for issue, not used for redeem: + // amount that has to be bought of the unwrapped token version (to cover required wrapped component amounts for issuance) + // this amount has to be computed beforehand through the exchange rate of wrapped component <> unwrapped component + // i.e. getRequiredComponentIssuanceUnits() on the IssuanceModule and then convert units through exchange rate to unwrapped component units + // e.g. 300 cDAI needed for issuance of 1 Set token. exchange rate 1cDAI = 0.05 DAI. -> buyUnderlyingAmount = 0.05 DAI * 300 = 15 DAI + buyUnderlyingAmount: BigNumber +} + +interface ComponentSwapDataRequest { + chainId: number + indexTokenSymbol: string + indexToken: string + indexTokenAmount: BigNumber +} + +interface WrappedToken { + address: string + decimals: number + underlyingErc20: { + address: string + decimals: number + symbol: string + } +} + +interface IssuanceRequest extends ComponentSwapDataRequest { + inputToken: string +} + +export async function getIssuanceComponentSwapData( + request: IssuanceRequest, + rpcUrl: string, + swapQuoteProvider: SwapQuoteProvider +): Promise { + const { + chainId, + indexTokenSymbol, + indexToken, + indexTokenAmount, + inputToken, + } = request + const issuance = getIssuanceContract(indexTokenSymbol, rpcUrl) + const [issuanceComponents, issuanceUnits] = + await issuance.getRequiredComponentIssuanceUnits( + indexToken, + indexTokenAmount + ) + const underlyingERC20sPromises: Promise[] = + issuanceComponents.map((component: string) => + getUnderlyingErc20(component, chainId) + ) + const amountPromises = issuanceComponents.map( + (component: Address, index: number) => + getAmount(component, issuanceUnits[index], chainId) + ) + const wrappedTokens = await Promise.all(underlyingERC20sPromises) + const amounts = await Promise.all(amountPromises) + const swapPromises: Promise[] = issuanceComponents.map( + (_: string, index: number) => { + const wrappedToken = wrappedTokens[index] + const underlyingERC20 = wrappedToken.underlyingErc20 + console.log( + underlyingERC20.symbol === USDC.symbol, + underlyingERC20.symbol, + USDC.symbol + ) + console.log( + isSameAddress(underlyingERC20.address, inputToken), + underlyingERC20.address, + inputToken + ) + if (isSameAddress(underlyingERC20.address, inputToken)) return null + return swapQuoteProvider.getSwapQuote({ + chainId, + inputToken, + outputToken: underlyingERC20.address, + outputAmount: amounts[index].toString(), + sources: [Exchange.UniV3], + }) + } + ) + const swapData = await Promise.all(swapPromises) + return buildComponentSwapData( + issuanceComponents, + wrappedTokens, + amounts, + swapData + ) +} + +interface RedemptionRequest extends ComponentSwapDataRequest { + outputToken: string +} + +export async function getRedemptionComponentSwapData( + request: RedemptionRequest, + rpcUrl: string, + swapQuoteProvider: SwapQuoteProvider +): Promise { + const { + chainId, + indexTokenSymbol, + indexToken, + indexTokenAmount, + outputToken, + } = request + const issuance = getIssuanceContract(indexTokenSymbol, rpcUrl) + const [issuanceComponents, issuanceUnits] = + await issuance.getRequiredComponentRedemptionUnits( + indexToken, + indexTokenAmount + ) + const underlyingERC20sPromises: Promise[] = + issuanceComponents.map((component: string) => + getUnderlyingErc20(component, chainId) + ) + const amountPromises = issuanceComponents.map( + (component: Address, index: number) => + getAmount(component, issuanceUnits[index], chainId) + ) + const wrappedTokens = await Promise.all(underlyingERC20sPromises) + const amounts = await Promise.all(amountPromises) + console.log(wrappedTokens) + const swapPromises: Promise[] = issuanceComponents.map( + (_: string, index: number) => { + const wrappedToken = wrappedTokens[index] + const underlyingERC20 = wrappedToken.underlyingErc20 + if (isSameAddress(underlyingERC20.address, outputToken)) return null + return swapQuoteProvider.getSwapQuote({ + chainId, + inputToken: underlyingERC20.address, + inputAmount: amounts[index].toString(), + outputToken, + sources: [Exchange.UniV3], + }) + } + ) + const swapData = await Promise.all(swapPromises) + return buildComponentSwapData( + issuanceComponents, + wrappedTokens, + amounts, + swapData + ) +} + +function buildComponentSwapData( + issuanceComponents: string[], + wrappedTokens: WrappedToken[], + buyAmounts: BigNumber[], + swapDataResults: (SwapQuote | null)[] +): ComponentSwapData[] { + return issuanceComponents.map((_: string, index: number) => { + const wrappedToken = wrappedTokens[index] + const buyUnderlyingAmount = buyAmounts[index] + const swapData = swapDataResults[index]?.swapData + const dexData: SwapDataV3 = swapData + ? { + exchange: swapData.exchange, + path: swapData.path, + fees: swapData.fees, + pool: swapData.pool, + poolIds: [], + } + : emptySwapData + return { + underlyingERC20: wrappedToken.underlyingErc20.address, + buyUnderlyingAmount, + dexData, + } + }) +} + +async function getAmount( + component: Address, + issuanceUnits: bigint, + chainId: number +): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const publicClient = createClient(chainId)! + const erc4626Abi = [ + 'function convertToAssets(uint256 shares) view returns (uint256 assets)', + 'function previewDeposit(uint256 assets) view returns (uint256)', + 'function previewMint(uint256 shares) view returns (uint256)', + 'function previewRedeem(uint256 shares) view returns (uint256)', + 'function previewWithdraw(uint256 assets) view returns (uint256)', + ] + const assets: bigint = (await publicClient.readContract({ + address: component as Address, + abi: erc4626Abi, + functionName: 'convertToAssets', + args: [issuanceUnits], + })) as bigint + return assets + } catch { + // TODO: apply slippage to issuance units amount (for all none erc4262) + return issuanceUnits + } + // const isFCASH = (address: string) => + // [ + // '0x278039398A5eb29b6c2FB43789a38A84C6085266', + // '0xe09B1968851478f20a43959d8a212051367dF01A', + // ].includes(address) + // const getAmountOfAssetToObtainShares = async () => + // component: string, + // shares: BigNumber, + // provider: JsonRpcProvider + // slippage = DEFAULT_SLIPPAGE // 1 = 1% + // { + // const componentContract = new Contract(component, erc4626Abi, provider) + // // Convert slippage to a BigNumber, do rounding to avoid weird JS precision loss + // const defaultSlippageBN = BigNumber.from(Math.round(slippage * 10000)) + // // if FCASH, increase slippage x3 + // const slippageBigNumber = isFCASH(component) + // ? defaultSlippageBN.mul(3) + // : defaultSlippageBN + // // Calculate the multiplier (1 + slippage) + // const multiplier = BigNumber.from(10000).add(slippageBigNumber) + // const buyUnderlyingAmount: BigNumber = + // await componentContract.convertToAssets(shares) + // return buyUnderlyingAmount.mul(multiplier).div(10000) + // } +} + +function getIssuanceContract( + indexTokenSymbol: string, + rpcUrl: string +): Contract { + const abi = [ + 'function getRequiredComponentIssuanceUnits(address _setToken, uint256 _quantity) external view returns (address[] memory, uint256[] memory, uint256[] memory)', + 'function getRequiredComponentRedemptionUnits(address _setToken, uint256 _quantity) external view returns (address[] memory, uint256[] memory, uint256[] memory)', + ] + const provider = getRpcProvider(rpcUrl) + const issuanceModule = getIssuanceModule(indexTokenSymbol) + return new Contract(issuanceModule.address, abi, provider) +} + +async function getUnderlyingErc20( + token: string, + chainId: number +): Promise { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const publicClient = createClient(chainId)! + const decimals: number = await publicClient.readContract({ + address: token as Address, + abi: parseAbi(['function decimals() view returns (uint8)']), + functionName: 'decimals', + }) + return { + address: token, + decimals, + underlyingErc20: { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + address: USDC.address!, + decimals: 6, + symbol: USDC.symbol, + }, + } +} diff --git a/src/utils/contracts.test.ts b/src/utils/contracts.test.ts index 32d9ea26..6b0e0aa0 100644 --- a/src/utils/contracts.test.ts +++ b/src/utils/contracts.test.ts @@ -31,6 +31,7 @@ import { getFlashMintZeroExContractForToken, getFlashMintLeveragedContractForToken, getFlashMintHyEthContract, + getFlashMintWrappedContract, } from './contracts' describe('getExchangeIssuanceLeveragedContractAddress()', () => { @@ -140,6 +141,20 @@ describe('getFlashMintLeveragedContractForToken()', () => { }) }) +describe('getFlashMintWrappedContract()', () => { + test('returns correct contract', async () => { + const expectedAddress = Contracts[ChainId.Mainnet].FlashMintWrapped + const contract = getFlashMintWrappedContract(undefined) + expect(contract.address).toEqual(expectedAddress) + expect(contract.functions.getIssueExactSet).toBeDefined() + expect(contract.functions.getRedeemExactSet).toBeDefined() + expect(contract.functions.issueExactSetFromERC20).toBeDefined() + expect(contract.functions.issueExactSetFromETH).toBeDefined() + expect(contract.functions.redeemExactSetForERC20).toBeDefined() + expect(contract.functions.redeemExactSetForETH).toBeDefined() + }) +}) + describe('getExchangeIssuanceZeroExContractAddress()', () => { test('return correct address for polygon', async () => { const expectedAddress = ExchangeIssuanceZeroExPolygonAddress diff --git a/src/utils/contracts.ts b/src/utils/contracts.ts index 661d20dd..2170861f 100644 --- a/src/utils/contracts.ts +++ b/src/utils/contracts.ts @@ -7,6 +7,7 @@ import EXCHANGE_ISSUANCE_ZERO_EX_ABI from '../constants/abis/ExchangeIssuanceZer import FLASHMINT_HYETH_ABI from '../constants/abis/FlashMintHyEth.json' import FLASHMINT_LEVERAGED_COMPOUND from '../constants/abis/FlashMintLeveragedForCompound.json' import FLASHMINT_LEVERAGED_EXTENDED_ABI from '../constants/abis/FlashMintLeveragedExtended.json' +import FLASHMINT_WRAPPED_ABI from '../constants/abis/FlashMintWrapped.json' import FLASHMINT_ZEROEX_ABI from '../constants/abis/FlashMintZeroEx.json' import { ChainId } from '../constants/chains' @@ -184,6 +185,21 @@ export const getFlashMintLeveragedContractForToken = ( } } +/** + * Returns an instance of a FlasthMintWrapped contract (mainnet only). + * @param signerOrProvider A signer or provider. + * @returns An instance of a FlasthMintWrapped contract. + */ +export const getFlashMintWrappedContract = ( + signerOrProvider: Signer | Provider | undefined +): Contract => { + return new Contract( + Contracts[ChainId.Mainnet].FlashMintWrapped, + FLASHMINT_WRAPPED_ABI, + signerOrProvider + ) +} + export function getExchangeIssuanceZeroExContractAddress( chainId: number = ChainId.Mainnet ): string { diff --git a/src/utils/index.ts b/src/utils/index.ts index 6dde6d95..f3bbc49d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './addresses' +export * from './component-swap-data' export * from './contracts' export * from './issuanceModules' export * from './numbers' @@ -7,3 +8,4 @@ export * from './slippage' export * from './swap-data' export * from './tokens' export * from './UniswapPath' +export * from './wrap-data' diff --git a/src/utils/issuanceModules.test.ts b/src/utils/issuanceModules.test.ts index b72facab..497c2441 100644 --- a/src/utils/issuanceModules.test.ts +++ b/src/utils/issuanceModules.test.ts @@ -19,6 +19,7 @@ import { IndexCoopBitcoin2xIndex, IndexCoopBitcoin3xIndex, HighYieldETHIndex, + TheUSDCYieldIndex, } from 'constants/tokens' import { getIssuanceModule } from './issuanceModules' @@ -59,6 +60,13 @@ describe('getIssuanceModule() - Mainnet - IndexProtocol', () => { expect(issuanceModule.isDebtIssuance).toBe(true) }) + test('returns debt issuance module v2 for icUSD', () => { + const expectedModule = Contracts[ChainId.Mainnet].DebtIssuanceModuleV3 + const issuanceModule = getIssuanceModule(TheUSDCYieldIndex.symbol) + expect(issuanceModule.address).toEqual(expectedModule) + expect(issuanceModule.isDebtIssuance).toBe(true) + }) + test('returns debt issuance module v2 for wsETH2', async () => { const expectedModule = IndexDebtIssuanceModuleV2Address const issuanceModule = getIssuanceModule(wsETH2.symbol) diff --git a/src/utils/issuanceModules.ts b/src/utils/issuanceModules.ts index d6c45dbe..d792f060 100644 --- a/src/utils/issuanceModules.ts +++ b/src/utils/issuanceModules.ts @@ -20,6 +20,7 @@ import { IndexCoopBitcoin2xIndex, HighYieldETHIndex, RealWorldAssetIndex, + TheUSDCYieldIndex, } from '../constants/tokens' export interface IssuanceModule { @@ -68,6 +69,11 @@ export function getIssuanceModule( return { address: IndexDebtIssuanceModuleV2Address, isDebtIssuance: true } case InterestCompoundingETHIndex.symbol: return { address: DebtIssuanceModuleV2Address, isDebtIssuance: true } + case TheUSDCYieldIndex.symbol: + return { + address: Contracts[ChainId.Mainnet].DebtIssuanceModuleV3, + isDebtIssuance: true, + } default: return { address: BasicIssuanceModuleAddress, isDebtIssuance: false } } diff --git a/src/utils/swap-data.ts b/src/utils/swap-data.ts index 84206bf1..6645104e 100644 --- a/src/utils/swap-data.ts +++ b/src/utils/swap-data.ts @@ -1,5 +1,5 @@ -// The order here has to be exactly the same as in the `DEXAdapter`` -// https://github.com/IndexCoop/index-coop-smart-contracts/blob/317dfb677e9738fc990cf69d198358065e8cb595/contracts/exchangeIssuance/DEXAdapter.sol#L53 +// The order here has to be exactly the same as in the `DEXAdapter` +// https://github.com/IndexCoop/index-coop-smart-contracts/blob/master/contracts/exchangeIssuance/DEXAdapterV3.sol#L54 export enum Exchange { None, Quickswap, @@ -15,3 +15,11 @@ export interface SwapData { fees: number[] pool: string } + +export interface SwapDataV3 { + exchange: Exchange + path: string[] + fees: number[] + pool: string + poolIds: string[] +} diff --git a/src/utils/wrap-data.test.ts b/src/utils/wrap-data.test.ts new file mode 100644 index 00000000..8d556a92 --- /dev/null +++ b/src/utils/wrap-data.test.ts @@ -0,0 +1,22 @@ +import { getWrapData } from './wrap-data' + +describe('getWrapData()', () => { + test('returns empty array for unsupported index', async () => { + const wrapData = getWrapData('DPI') + expect(wrapData.length).toBe(0) + }) + + test.only('returns correct wrap data for icUSD', async () => { + const wrapData = getWrapData('icUSD') + // TODO: update for components (after presale/rebalance) + expect(wrapData.length).toBe(1) + // expect(wrapData[0].integrationName).toBe('') + // expect(wrapData[1].integrationName).toBe('Aave_V3_Wrap_V2_Adapter') + // expect(wrapData[2].integrationName).toBe('Compound_V3_USDC_Wrap_V2_Adapter') + // expect(wrapData[3].integrationName).toBe('Aave_V2_Wrap_V2_Adapter') + // expect(wrapData[4].integrationName).toBe('ERC4626_Wrap_V2_Adapter') + // wrapData.forEach((data) => { + // expect(data.wrapData).toBe('0x0000000000000000000000000000000000000000') + // }) + }) +}) diff --git a/src/utils/wrap-data.ts b/src/utils/wrap-data.ts new file mode 100644 index 00000000..dc6fdc5f --- /dev/null +++ b/src/utils/wrap-data.ts @@ -0,0 +1,45 @@ +import { TheUSDCYieldIndex } from 'constants/tokens' + +enum IntegrationName { + aaveV2WrapV2Adapter = 'AaveV2WrapV2Adapter', + aaveV3WrapV2Adapter = 'AaveV3WrapV2Adapter', + compoundV3UsdcWrapV2Adapter = 'CompoundV3WrapV2Adapter', + erc4626WrapV2Adapter = 'ERC4626WrapV2Adapter', + wrapModuleV2Contract = 'WrapModuleV2', +} + +export interface ComponentWrapData { + // wrap adapter integration name as listed in the IntegrationRegistry for the wrapModule + integrationName: IntegrationName | '' + // optional wrapData passed to the wrapAdapter + wrapData: string +} + +const ZERO_BYTES = '0x0000000000000000000000000000000000000000' + +export function getWrapData(tokenSymbol: string): ComponentWrapData[] { + if (tokenSymbol !== TheUSDCYieldIndex.symbol) return [] + return [ + { + integrationName: '', + wrapData: ZERO_BYTES, + }, + // TODO: update once rebalanced + // { + // integrationName: aaveV3WrapV2AdapterName, + // wrapData: ZERO_BYTES, + // }, + // { + // integrationName: compoundV3WrapV2AdapterName, + // wrapData: ZERO_BYTES, + // }, + // { + // integrationName: aaveV2WrapV2AdapterName, + // wrapData: ZERO_BYTES, + // }, + // { + // integrationName: erc4626WrapV2AdapterName, + // wrapData: ZERO_BYTES, + // }, + ] +}