diff --git a/.changeset/perfect-knives-draw.md b/.changeset/perfect-knives-draw.md deleted file mode 100644 index 73171cc043..0000000000 --- a/.changeset/perfect-knives-draw.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@coinbase/onchainkit": patch ---- - -***feat*** Added NFT data hooks. By @alessey #1838 diff --git a/CHANGELOG.md b/CHANGELOG.md index d3fce8e4f1..b00ce034ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,22 @@ # Changelog +## 0.36.8 + +### Patch Changes + +- **feat**: Add Popover UI Primitive. By @cpcramer #1849 +- **feat**: Added NFT data hooks. By @alessey #1838 +- **fix**: Made improvements to FundCard component. By @rustam-cb #1806 #1818 #1839 +- **fix**: Made improvements to WalletIsland component. By @brendan-cb #1842 +- **fix**: Made internal typesafety and efficiency improvements. By @dschlabach #1855 #1843 +- **fix**: Fixed typos in documentation and commments. By @youyyytrok @vipocenka #1840 #1841 + ## 0.36.7 ### Patch Changes - - **feat** Implemented `FundCard` component by @rustam-cb #1718 - - **fix** Updated client boundaries for `NFT`, `Wallet*`, and `WalletAdvanced*` components by @dschlabach #1809, #1810, #1821 +- **feat**: Implemented `FundCard` component by @rustam-cb #1718 +- **fix**: Updated client boundaries for `NFT`, `Wallet*`, and `WalletAdvanced*` components by @dschlabach #1809, #1810, #1821 ## 0.36.6 diff --git a/package.json b/package.json index 084b5a8473..876438b38a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/onchainkit", - "version": "0.36.7", + "version": "0.36.8", "type": "module", "repository": "https://github.com/coinbase/onchainkit.git", "license": "MIT", diff --git a/playground/nextjs-app-router/onchainkit/package.json b/playground/nextjs-app-router/onchainkit/package.json index 49daea2170..425b7c2d82 100644 --- a/playground/nextjs-app-router/onchainkit/package.json +++ b/playground/nextjs-app-router/onchainkit/package.json @@ -116,10 +116,10 @@ "default": "./esm/index.js" }, "./api": { - "types": "./esm/core/api/index.d.ts", - "module": "./esm/core/api/index.js", - "import": "./esm/core/api/index.js", - "default": "./esm/core/api/index.js" + "types": "./esm/api/index.d.ts", + "module": "./esm/api/index.js", + "import": "./esm/api/index.js", + "default": "./esm/api/index.js" }, "./buy": { "types": "./esm/buy/index.d.ts", @@ -146,10 +146,10 @@ "default": "./esm/fund/index.js" }, "./identity": { - "types": "./esm/ui/react/identity/index.d.ts", - "module": "./esm/ui/react/identity/index.js", - "import": "./esm/ui/react/identity/index.js", - "default": "./esm/ui/react/identity/index.js" + "types": "./esm/identity/index.d.ts", + "module": "./esm/identity/index.js", + "import": "./esm/identity/index.js", + "default": "./esm/identity/index.js" }, "./nft": { "types": "./esm/ui/react/nft/index.d.ts", diff --git a/site/docs/pages/fund/fund-card.mdx b/site/docs/pages/fund/fund-card.mdx new file mode 100644 index 0000000000..dfbecd4bba --- /dev/null +++ b/site/docs/pages/fund/fund-card.mdx @@ -0,0 +1,234 @@ +--- +title: ยท OnchainKit +description: The `` component provides a complete fiat onramp experience with amount input, currency switching, and payment method selection. +--- + +import { Avatar, Name } from '@coinbase/onchainkit/identity'; +import { FundCard } from '@coinbase/onchainkit/fund'; +import { Wallet, ConnectWallet } from '@coinbase/onchainkit/wallet'; +import App from '../../components/App'; +import FundWrapper from '../../components/FundWrapper'; + +# `` + +The `` component provides a complete fiat onramp experience within your app. It includes: + +- Amount input with fiat/crypto switching +- Payment method selection (Coinbase, Apple Pay, Debit Card) +- Automatic exchange rate updates +- Smart handling of payment method restrictions (based on country and subdivision) + +:::info +The Apple Pay and Debit Card onramp options are only available for Coinbase supported assets. +::: + +## Prerequisites + +Before using the `FundCard` component, ensure you've completed the [Getting Started](/installation/nextjs#get-your-client-api-key) steps. + +::::tip +To use the `FundCard` component, you'll need to provide a Client API Key in `OnchainKitProvider`. You can get one following our [Getting Started](/installation/nextjs#get-your-client-api-key) steps. +::::: + +## Usage + +### Drop in the `` component + +```tsx +import { FundCard } from '@coinbase/onchainkit/fund'; + +; +``` + + + + {({ address }) => { + if (address) { + return ; + } + return ( + <> + + + + + + + + ); + }} + + + +## Customization + +### Custom Header and Button Text + +You can customize the header and button text: + +```tsx + +``` + + + + {({ address }) => { + if (address) { + return ( + + ); + } + return ( + <> + + + + + + + + ); + }} + + + +### Custom Currency + +You can specify which fiat currency to use: + +```tsx + +``` + +### Preset Amount Inputs + +You can specify preset amount buttons: + +```tsx +const presetAmountInputs = ['10', '20', '50'] as const; + +; +``` + +:::info +**Note: 3 preset amount inputs are required in order to show the preset amount buttons.** +::: + +### Custom Content + +You can provide custom children to completely customize the card content while keeping the fund button functionality: + +```tsx + +
+

Custom Header

+ + +
+
+``` + +You can also reuse the existing children from the default implementation and add your own custom content. + +```tsx +import { + FundCardHeader, + FundCardAmountInput, + FundCardAmountInputTypeSwitch, + FundCardPresetAmountInputList, + FundCardPaymentMethodDropdown, + FundCardSubmitButton, + } from '@coinbase/onchainkit/fund'; + + + +

Custom Header instead of the default "FundCardHeader"

+ + + +
Any custom content
+ + +
Any custom content
+
+``` + +:::info +**Note:** If you are using the custom components then you are going to need to access the state of the `FundCard` component. You can do this by using the `useFundContext` hook. +::: + +```tsx +const { + asset, + currency, + selectedPaymentMethod, + setSelectedPaymentMethod, + fundAmountFiat, + setFundAmountFiat, + fundAmountCrypto, + setFundAmountCrypto, + selectedInputType, + setSelectedInputType, + exchangeRate, + setExchangeRate, + exchangeRateLoading, + setExchangeRateLoading, + submitButtonState, + setSubmitButtonState, + paymentMethods, + setPaymentMethods, + paymentMethodsLoading, + setPaymentMethodsLoading, + headerText, + buttonText, + country, + subdivision, + lifecycleStatus, + updateLifecycleStatus, + presetAmountInputs, + } = useFundContext(); +``` + +## Props + +- [`FundCardPropsReact`](/fund/types#fundcardpropsreact) + +## Related Components + +- [``](/fund/fund-button) diff --git a/site/package.json b/site/package.json index 3d68bc656e..d7caf8467b 100644 --- a/site/package.json +++ b/site/package.json @@ -9,7 +9,7 @@ "sitemap": "node createSitemap.js" }, "dependencies": { - "@coinbase/onchainkit": "0.36.7", + "@coinbase/onchainkit": "0.36.8", "@types/react": "latest", "@vercel/edge": "^1.1.1", "express": "^4.21.1", diff --git a/site/sidebar.ts b/site/sidebar.ts index 1fda639ec2..967ad21068 100644 --- a/site/sidebar.ts +++ b/site/sidebar.ts @@ -109,6 +109,10 @@ export const sidebar = [ text: 'FundButton', link: '/fund/fund-button', }, + { + text: 'FundCard', + link: '/fund/fund-card', + }, ], }, { diff --git a/site/yarn.lock b/site/yarn.lock index fd63e181f2..1c1e12a049 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -479,9 +479,9 @@ __metadata: languageName: node linkType: hard -"@coinbase/onchainkit@npm:0.36.7": - version: 0.36.7 - resolution: "@coinbase/onchainkit@npm:0.36.7" +"@coinbase/onchainkit@npm:0.36.8": + version: 0.36.8 + resolution: "@coinbase/onchainkit@npm:0.36.8" dependencies: "@tanstack/react-query": "npm:^5" clsx: "npm:^2.1.1" @@ -496,7 +496,7 @@ __metadata: "@types/react": ^18 || ^19 react: ^18 || ^19 react-dom: ^18 || ^19 - checksum: 3ffca599b9a4f2a33e9aef0aec15b3d03ec6009ea8863152117e02e4f51f84e86950779b6594f49c46f9bfd6c1ae2eae2d47b58d14fa29bf1534f44b738e7826 + checksum: 73eca5767193f98addd2185695b1f8adf7c82e1d5664dfd7de9d07467b4997d6a7b5311fdd1b8dca482bf600022844f24321e6e7787fb46aa18c4410d576c876 languageName: node linkType: hard @@ -8408,7 +8408,7 @@ __metadata: version: 0.0.0-use.local resolution: "onchainkit@workspace:." dependencies: - "@coinbase/onchainkit": "npm:0.36.7" + "@coinbase/onchainkit": "npm:0.36.8" "@types/express": "npm:^4" "@types/react": "npm:latest" "@types/sitemap-generator": "npm:^8" diff --git a/src/api/buildSwapTransaction.test.ts b/src/api/buildSwapTransaction.test.ts index ec49ecd8d6..39b1f4798d 100644 --- a/src/api/buildSwapTransaction.test.ts +++ b/src/api/buildSwapTransaction.test.ts @@ -1,6 +1,9 @@ import { CDP_GET_SWAP_TRADE } from '@/core/network/definitions/swap'; import { sendRequest } from '@/core/network/request'; +import { SwapMessage } from '@/swap/constants'; +import { UNSUPPORTED_AMOUNT_REFERENCE_ERROR_CODE } from '@/swap/constants'; import { DEGEN_TOKEN, ETH_TOKEN } from '@/swap/mocks'; +import type { Address } from 'viem'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildSwapTransaction } from './buildSwapTransaction'; import type { BuildSwapTransaction } from './types'; @@ -12,9 +15,9 @@ import { getSwapTransaction } from './utils/getSwapTransaction'; vi.mock('@/core/network/request'); -const testFromAddress = '0x6Cd01c0F55ce9E0Bf78f5E90f72b4345b16d515d'; +const testFromAddress: Address = '0x6Cd01c0F55ce9E0Bf78f5E90f72b4345b16d515d'; const testAmount = '3305894409732200'; -const testAmountReference = 'from'; +const testAmountReference = 'from' as const; describe('buildSwapTransaction', () => { beforeEach(() => { @@ -24,7 +27,7 @@ describe('buildSwapTransaction', () => { it('should return a swap', async () => { const mockParams = { useAggregator: true, - fromAddress: testFromAddress as `0x${string}`, + fromAddress: testFromAddress, amountReference: testAmountReference, from: ETH_TOKEN, to: DEGEN_TOKEN, @@ -120,7 +123,7 @@ describe('buildSwapTransaction', () => { it('should return a swap with useAggregator=false', async () => { const mockParams = { useAggregator: false, - fromAddress: testFromAddress as `0x${string}`, + fromAddress: testFromAddress, amountReference: testAmountReference, from: ETH_TOKEN, to: DEGEN_TOKEN, @@ -199,11 +202,28 @@ describe('buildSwapTransaction', () => { ]); }); + it('should return an error for an unsupported amount reference', async () => { + const mockParams = { + useAggregator: true, + fromAddress: testFromAddress, + amountReference: 'to' as const, + from: ETH_TOKEN, + to: DEGEN_TOKEN, + amount: testAmount, + }; + const error = await buildSwapTransaction(mockParams); + expect(error).toEqual({ + code: UNSUPPORTED_AMOUNT_REFERENCE_ERROR_CODE, + error: SwapMessage.UNSUPPORTED_AMOUNT_REFERENCE, + message: '', + }); + }); + it('should return a swap with an approve transaction', async () => { const mockParams = { useAggregator: true, maxSlippage: '3', - fromAddress: testFromAddress as `0x${string}`, + fromAddress: testFromAddress, amountReference: testAmountReference, from: DEGEN_TOKEN, to: ETH_TOKEN, @@ -292,7 +312,7 @@ describe('buildSwapTransaction', () => { it('should return an error if sendRequest fails', async () => { const mockParams = { useAggregator: true, - fromAddress: testFromAddress as `0x${string}`, + fromAddress: testFromAddress, amountReference: testAmountReference, from: ETH_TOKEN, to: DEGEN_TOKEN, @@ -318,7 +338,7 @@ describe('buildSwapTransaction', () => { it('should return an error object from buildSwapTransaction', async () => { const mockParams = { useAggregator: true, - fromAddress: testFromAddress as `0x${string}`, + fromAddress: testFromAddress, amountReference: testAmountReference, from: ETH_TOKEN, to: DEGEN_TOKEN, @@ -349,7 +369,7 @@ describe('buildSwapTransaction', () => { it('should return an error object from buildSwapTransaction for invalid `amount` input', async () => { const mockParams = { useAggregator: true, - fromAddress: testFromAddress as `0x${string}`, + fromAddress: testFromAddress, amountReference: testAmountReference, from: ETH_TOKEN, to: DEGEN_TOKEN, @@ -368,7 +388,7 @@ describe('buildSwapTransaction', () => { const mockParams = { useAggregator: true, maxSlippage: '3', - fromAddress: testFromAddress as `0x${string}`, + fromAddress: testFromAddress, amountReference: testAmountReference, from: ETH_TOKEN, to: DEGEN_TOKEN, diff --git a/src/api/buildSwapTransaction.ts b/src/api/buildSwapTransaction.ts index 54b3cca9fe..54b17731d3 100644 --- a/src/api/buildSwapTransaction.ts +++ b/src/api/buildSwapTransaction.ts @@ -1,3 +1,5 @@ +import { SwapMessage } from '@/swap/constants'; +import { UNSUPPORTED_AMOUNT_REFERENCE_ERROR_CODE } from '@/swap/constants'; import { CDP_GET_SWAP_TRADE } from '../core/network/definitions/swap'; import { sendRequest } from '../core/network/request'; import type { SwapAPIResponse } from '../swap/types'; @@ -18,7 +20,7 @@ export async function buildSwapTransaction( ): Promise { // Default parameters const defaultParams = { - amountReference: 'from', + amountReference: 'from' as const, isAmountInDecimals: false, }; @@ -30,6 +32,15 @@ export async function buildSwapTransaction( return apiParams; } + if (params.useAggregator && params.amountReference === 'to') { + console.error(SwapMessage.UNSUPPORTED_AMOUNT_REFERENCE); + return { + code: UNSUPPORTED_AMOUNT_REFERENCE_ERROR_CODE, + error: SwapMessage.UNSUPPORTED_AMOUNT_REFERENCE, + message: '', + }; + } + if (!params.useAggregator) { apiParams = { v2Enabled: true, diff --git a/src/api/getSwapQuote.test.ts b/src/api/getSwapQuote.test.ts index 02b85d9052..27557ba538 100644 --- a/src/api/getSwapQuote.test.ts +++ b/src/api/getSwapQuote.test.ts @@ -1,5 +1,7 @@ import { CDP_GET_SWAP_QUOTE } from '@/core/network/definitions/swap'; import { sendRequest } from '@/core/network/request'; +import { SwapMessage } from '@/swap/constants'; +import { UNSUPPORTED_AMOUNT_REFERENCE_ERROR_CODE } from '@/swap/constants'; import { type Mock, afterEach, describe, expect, it, vi } from 'vitest'; import { DEGEN_TOKEN, ETH_TOKEN } from '../swap/mocks'; /** @@ -11,7 +13,7 @@ import { getAPIParamsForToken } from './utils/getAPIParamsForToken'; vi.mock('@/core/network/request'); const testAmount = '3305894409732200'; -const testAmountReference = 'from'; +const testAmountReference = 'from' as const; describe('getSwapQuote', () => { afterEach(() => { @@ -91,6 +93,22 @@ describe('getSwapQuote', () => { ]); }); + it('should return an error for an unsupported amount reference', async () => { + const mockParams = { + useAggregator: true, + amountReference: 'to' as const, + from: ETH_TOKEN, + to: DEGEN_TOKEN, + amount: testAmount, + }; + const error = await getSwapQuote(mockParams); + expect(error).toEqual({ + code: UNSUPPORTED_AMOUNT_REFERENCE_ERROR_CODE, + error: SwapMessage.UNSUPPORTED_AMOUNT_REFERENCE, + message: '', + }); + }); + it('should return an error if sendRequest fails', async () => { const mockParams = { useAggregator: true, diff --git a/src/api/getSwapQuote.ts b/src/api/getSwapQuote.ts index 61f2624066..c34d1c4202 100644 --- a/src/api/getSwapQuote.ts +++ b/src/api/getSwapQuote.ts @@ -1,3 +1,5 @@ +import { SwapMessage } from '@/swap/constants'; +import { UNSUPPORTED_AMOUNT_REFERENCE_ERROR_CODE } from '@/swap/constants'; import { CDP_GET_SWAP_QUOTE } from '../core/network/definitions/swap'; import { sendRequest } from '../core/network/request'; import type { SwapQuote } from '../swap/types'; @@ -17,7 +19,7 @@ export async function getSwapQuote( ): Promise { // Default parameters const defaultParams = { - amountReference: 'from', + amountReference: 'from' as const, isAmountInDecimals: false, }; let apiParams = getAPIParamsForToken({ @@ -28,6 +30,15 @@ export async function getSwapQuote( return apiParams; } + if (params.useAggregator && params.amountReference === 'to') { + console.error(SwapMessage.UNSUPPORTED_AMOUNT_REFERENCE); + return { + code: UNSUPPORTED_AMOUNT_REFERENCE_ERROR_CODE, + error: SwapMessage.UNSUPPORTED_AMOUNT_REFERENCE, + message: '', + }; + } + if (!params.useAggregator) { apiParams = { v2Enabled: true, diff --git a/src/api/types.ts b/src/api/types.ts index 9a6a02e656..2d5ab2d8dd 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -72,7 +72,7 @@ export type GetAPIParamsForToken = export type GetQuoteAPIParams = { amount: string; // The amount to be swapped - amountReference?: string; // The reference amount for the swap + amountReference?: 'to' | 'from'; // The reference amount for the swap, 'to' is only supported with v2Enabled: false from: AddressOrETH | ''; // The source address or 'ETH' for Ethereum to: AddressOrETH | ''; // The destination address or 'ETH' for Ethereum v2Enabled?: boolean; // Whether to use V2 of the API (default: false) @@ -88,7 +88,7 @@ export type GetSwapAPIParams = GetQuoteAPIParams & { */ export type GetSwapQuoteParams = { amount: string; // The amount to be swapped - amountReference?: string; // The reference amount for the swap + amountReference?: 'to' | 'from'; // The reference amount for the swap, 'to' is only supported with v2Enabled: false from: Token; // The source token for the swap isAmountInDecimals?: boolean; // Whether the amount is in decimals maxSlippage?: string; // The slippage of the swap diff --git a/src/buy/components/BuyAmountInput.tsx b/src/buy/components/BuyAmountInput.tsx index a62b376768..22081c9423 100644 --- a/src/buy/components/BuyAmountInput.tsx +++ b/src/buy/components/BuyAmountInput.tsx @@ -30,6 +30,7 @@ export function BuyAmountInput() { )} placeholder="0.0" delayMs={1000} + inputMode="decimal" value={formatAmount(to.amount)} setValue={to.setAmount} disabled={to.loading} diff --git a/src/buy/components/BuyProvider.test.tsx b/src/buy/components/BuyProvider.test.tsx index 4e831a2727..b9e8f73b6e 100644 --- a/src/buy/components/BuyProvider.test.tsx +++ b/src/buy/components/BuyProvider.test.tsx @@ -693,7 +693,6 @@ describe('BuyProvider', () => { expect.objectContaining({ maxSlippage: '10', amount: '5', - amountReference: 'to', from: ethToken, to: degenToken, useAggregator: true, @@ -720,33 +719,6 @@ describe('BuyProvider', () => { }); }); - it('should pass the correct amountReference to get', async () => { - const TestComponent = () => { - const { handleAmountChange } = useBuyContext(); - // biome-ignore lint: hello - React.useEffect(() => { - const initializeSwap = () => { - handleAmountChange('100'); - }; - initializeSwap(); - }, []); - return null; - }; - await act(async () => { - renderWithProviders({ Component: TestComponent }); - }); - expect(getBuyQuote).toHaveBeenCalledWith( - expect.objectContaining({ - maxSlippage: '10', - amount: '100', - amountReference: 'to', - from: ethToken, - to: degenToken, - useAggregator: true, - }), - ); - }); - it('should handle undefined in input', async () => { const TestComponent = () => { const { handleAmountChange } = useBuyContext(); diff --git a/src/buy/components/BuyProvider.tsx b/src/buy/components/BuyProvider.tsx index a4dc2fe229..5ba3fa06f5 100644 --- a/src/buy/components/BuyProvider.tsx +++ b/src/buy/components/BuyProvider.tsx @@ -256,7 +256,6 @@ export function BuyProvider({ formattedFromAmount: formattedAmountETH, } = await getBuyQuote({ amount, - amountReference: 'to', from: fromETH.token, maxSlippage: String(maxSlippage), to: to.token, @@ -269,7 +268,6 @@ export function BuyProvider({ formattedFromAmount: formattedAmountUSDC, } = await getBuyQuote({ amount, - amountReference: 'to', from: fromUSDC.token, maxSlippage: String(maxSlippage), to: to.token, @@ -282,7 +280,6 @@ export function BuyProvider({ formattedFromAmount: formattedAmountFrom, } = await getBuyQuote({ amount, - amountReference: 'to', from: from?.token, maxSlippage: String(maxSlippage), to: to.token, diff --git a/src/buy/utils/getBuyQuote.test.ts b/src/buy/utils/getBuyQuote.test.ts index d563537ae3..779b76d262 100644 --- a/src/buy/utils/getBuyQuote.test.ts +++ b/src/buy/utils/getBuyQuote.test.ts @@ -33,27 +33,26 @@ const fromToken: Token = { const mockResponse = { from: fromToken, to: toToken, - fromAmount: '100000000000000000', - toAmount: '16732157880511600003860', + fromAmount: '16732157880511600003860', + toAmount: '100000000000000000', amountReference: 'from', priceImpact: '0.07', chainId: 8453, hasHighPriceImpact: false, slippage: '3', - fromAmountUSD: '100', + toAmountUSD: '100', }; const mockEmptyResponse = { from: fromToken, to: toToken, - fromAmount: '', - toAmount: '16732157880511600003860', - amountReference: 'from', + toAmount: '', + fromAmount: '16732157880511600003860', priceImpact: '0.07', chainId: 8453, hasHighPriceImpact: false, slippage: '3', - fromAmountUSD: '', + toAmountUSD: '', }; const mockFromSwapUnit = { @@ -74,7 +73,6 @@ describe('getBuyQuote', () => { it('should return default values if `from` token is not provided', async () => { const result = await getBuyQuote({ amount: '1', - amountReference: 'exactIn', maxSlippage: '0.5', to: toToken, useAggregator: true, @@ -95,7 +93,6 @@ describe('getBuyQuote', () => { const result = await getBuyQuote({ amount: '1', - amountReference: 'exactIn', from: fromToken, maxSlippage: '0.5', to: toToken, @@ -105,10 +102,10 @@ describe('getBuyQuote', () => { expect(getSwapQuote).toHaveBeenCalledWith({ amount: '1', - amountReference: 'exactIn', - from: fromToken, + amountReference: 'from', + from: toToken, maxSlippage: '0.5', - to: toToken, + to: fromToken, useAggregator: true, }); @@ -130,7 +127,6 @@ describe('getBuyQuote', () => { const result = await getBuyQuote({ amount: '1', - amountReference: 'exactIn', from: fromToken, maxSlippage: '0.5', to: toToken, @@ -140,10 +136,10 @@ describe('getBuyQuote', () => { expect(getSwapQuote).toHaveBeenCalledWith({ amount: '1', - amountReference: 'exactIn', - from: fromToken, + amountReference: 'from', + from: toToken, maxSlippage: '0.5', - to: toToken, + to: fromToken, useAggregator: true, }); @@ -170,7 +166,6 @@ describe('getBuyQuote', () => { const result = await getBuyQuote({ amount: '1', - amountReference: 'exactIn', from: fromToken, maxSlippage: '0.5', to: toToken, @@ -189,7 +184,7 @@ describe('getBuyQuote', () => { it('should not call `getSwapQuote` if `from` and `to` tokens are the same', async () => { const result = await getBuyQuote({ amount: '1', - amountReference: 'exactIn', + amountReference: 'from' as const, from: fromToken, maxSlippage: '0.5', to: fromToken, diff --git a/src/buy/utils/getBuyQuote.ts b/src/buy/utils/getBuyQuote.ts index 6c6e669547..a81cb5ce70 100644 --- a/src/buy/utils/getBuyQuote.ts +++ b/src/buy/utils/getBuyQuote.ts @@ -17,7 +17,6 @@ type GetBuyQuoteParams = Omit & { export async function getBuyQuote({ amount, - amountReference, from, maxSlippage, to, @@ -32,23 +31,26 @@ export async function getBuyQuote({ let response: GetSwapQuoteResponse | undefined; // only fetch quote if the from and to tokens are different if (to?.symbol !== from?.symbol) { + // switching to and from here + // instead of getting a quote for how much of X do we need to sell to get the input token amount + // we can get a quote for how much of X we will recieve if we sell the input token amount response = await getSwapQuote({ amount, - amountReference, - from, + amountReference: 'from', + from: to, maxSlippage, - to, + to: from, useAggregator, }); } let formattedFromAmount = ''; if (response && !isSwapError(response)) { - formattedFromAmount = response?.fromAmount - ? formatTokenAmount(response.fromAmount, response.from.decimals) + formattedFromAmount = response?.toAmount + ? formatTokenAmount(response.toAmount, response.to.decimals) : ''; - fromSwapUnit?.setAmountUSD(response?.fromAmountUSD || ''); + fromSwapUnit?.setAmountUSD(response?.toAmountUSD || ''); fromSwapUnit?.setAmount(formattedFromAmount || ''); } diff --git a/src/earn/abis/morpho.ts b/src/earn/abis/morpho.ts new file mode 100644 index 0000000000..8c0860f8a3 --- /dev/null +++ b/src/earn/abis/morpho.ts @@ -0,0 +1,1400 @@ +export const MORPHO_VAULT_ABI = [ + { + inputs: [ + { internalType: 'address', name: 'owner', type: 'address' }, + { internalType: 'address', name: 'morpho', type: 'address' }, + { internalType: 'uint256', name: 'initialTimelock', type: 'uint256' }, + { internalType: 'address', name: '_asset', type: 'address' }, + { internalType: 'string', name: '_name', type: 'string' }, + { internalType: 'string', name: '_symbol', type: 'string' }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { inputs: [], name: 'AboveMaxTimelock', type: 'error' }, + { + inputs: [{ internalType: 'address', name: 'target', type: 'address' }], + name: 'AddressEmptyCode', + type: 'error', + }, + { + inputs: [{ internalType: 'address', name: 'account', type: 'address' }], + name: 'AddressInsufficientBalance', + type: 'error', + }, + { inputs: [], name: 'AllCapsReached', type: 'error' }, + { inputs: [], name: 'AlreadyPending', type: 'error' }, + { inputs: [], name: 'AlreadySet', type: 'error' }, + { inputs: [], name: 'BelowMinTimelock', type: 'error' }, + { + inputs: [{ internalType: 'Id', name: 'id', type: 'bytes32' }], + name: 'DuplicateMarket', + type: 'error', + }, + { inputs: [], name: 'ECDSAInvalidSignature', type: 'error' }, + { + inputs: [{ internalType: 'uint256', name: 'length', type: 'uint256' }], + name: 'ECDSAInvalidSignatureLength', + type: 'error', + }, + { + inputs: [{ internalType: 'bytes32', name: 's', type: 'bytes32' }], + name: 'ECDSAInvalidSignatureS', + type: 'error', + }, + { + inputs: [ + { internalType: 'address', name: 'spender', type: 'address' }, + { internalType: 'uint256', name: 'allowance', type: 'uint256' }, + { internalType: 'uint256', name: 'needed', type: 'uint256' }, + ], + name: 'ERC20InsufficientAllowance', + type: 'error', + }, + { + inputs: [ + { internalType: 'address', name: 'sender', type: 'address' }, + { internalType: 'uint256', name: 'balance', type: 'uint256' }, + { internalType: 'uint256', name: 'needed', type: 'uint256' }, + ], + name: 'ERC20InsufficientBalance', + type: 'error', + }, + { + inputs: [{ internalType: 'address', name: 'approver', type: 'address' }], + name: 'ERC20InvalidApprover', + type: 'error', + }, + { + inputs: [{ internalType: 'address', name: 'receiver', type: 'address' }], + name: 'ERC20InvalidReceiver', + type: 'error', + }, + { + inputs: [{ internalType: 'address', name: 'sender', type: 'address' }], + name: 'ERC20InvalidSender', + type: 'error', + }, + { + inputs: [{ internalType: 'address', name: 'spender', type: 'address' }], + name: 'ERC20InvalidSpender', + type: 'error', + }, + { + inputs: [{ internalType: 'uint256', name: 'deadline', type: 'uint256' }], + name: 'ERC2612ExpiredSignature', + type: 'error', + }, + { + inputs: [ + { internalType: 'address', name: 'signer', type: 'address' }, + { internalType: 'address', name: 'owner', type: 'address' }, + ], + name: 'ERC2612InvalidSigner', + type: 'error', + }, + { + inputs: [ + { internalType: 'address', name: 'receiver', type: 'address' }, + { internalType: 'uint256', name: 'assets', type: 'uint256' }, + { internalType: 'uint256', name: 'max', type: 'uint256' }, + ], + name: 'ERC4626ExceededMaxDeposit', + type: 'error', + }, + { + inputs: [ + { internalType: 'address', name: 'receiver', type: 'address' }, + { internalType: 'uint256', name: 'shares', type: 'uint256' }, + { internalType: 'uint256', name: 'max', type: 'uint256' }, + ], + name: 'ERC4626ExceededMaxMint', + type: 'error', + }, + { + inputs: [ + { internalType: 'address', name: 'owner', type: 'address' }, + { internalType: 'uint256', name: 'shares', type: 'uint256' }, + { internalType: 'uint256', name: 'max', type: 'uint256' }, + ], + name: 'ERC4626ExceededMaxRedeem', + type: 'error', + }, + { + inputs: [ + { internalType: 'address', name: 'owner', type: 'address' }, + { internalType: 'uint256', name: 'assets', type: 'uint256' }, + { internalType: 'uint256', name: 'max', type: 'uint256' }, + ], + name: 'ERC4626ExceededMaxWithdraw', + type: 'error', + }, + { inputs: [], name: 'FailedInnerCall', type: 'error' }, + { + inputs: [{ internalType: 'Id', name: 'id', type: 'bytes32' }], + name: 'InconsistentAsset', + type: 'error', + }, + { inputs: [], name: 'InconsistentReallocation', type: 'error' }, + { + inputs: [ + { internalType: 'address', name: 'account', type: 'address' }, + { internalType: 'uint256', name: 'currentNonce', type: 'uint256' }, + ], + name: 'InvalidAccountNonce', + type: 'error', + }, + { + inputs: [{ internalType: 'Id', name: 'id', type: 'bytes32' }], + name: 'InvalidMarketRemovalNonZeroCap', + type: 'error', + }, + { + inputs: [{ internalType: 'Id', name: 'id', type: 'bytes32' }], + name: 'InvalidMarketRemovalNonZeroSupply', + type: 'error', + }, + { + inputs: [{ internalType: 'Id', name: 'id', type: 'bytes32' }], + name: 'InvalidMarketRemovalTimelockNotElapsed', + type: 'error', + }, + { inputs: [], name: 'InvalidShortString', type: 'error' }, + { inputs: [], name: 'MarketNotCreated', type: 'error' }, + { + inputs: [{ internalType: 'Id', name: 'id', type: 'bytes32' }], + name: 'MarketNotEnabled', + type: 'error', + }, + { inputs: [], name: 'MathOverflowedMulDiv', type: 'error' }, + { inputs: [], name: 'MaxFeeExceeded', type: 'error' }, + { inputs: [], name: 'MaxQueueLengthExceeded', type: 'error' }, + { inputs: [], name: 'NoPendingValue', type: 'error' }, + { inputs: [], name: 'NonZeroCap', type: 'error' }, + { inputs: [], name: 'NotAllocatorRole', type: 'error' }, + { inputs: [], name: 'NotCuratorNorGuardianRole', type: 'error' }, + { inputs: [], name: 'NotCuratorRole', type: 'error' }, + { inputs: [], name: 'NotEnoughLiquidity', type: 'error' }, + { inputs: [], name: 'NotGuardianRole', type: 'error' }, + { + inputs: [{ internalType: 'address', name: 'owner', type: 'address' }], + name: 'OwnableInvalidOwner', + type: 'error', + }, + { + inputs: [{ internalType: 'address', name: 'account', type: 'address' }], + name: 'OwnableUnauthorizedAccount', + type: 'error', + }, + { + inputs: [{ internalType: 'Id', name: 'id', type: 'bytes32' }], + name: 'PendingCap', + type: 'error', + }, + { inputs: [], name: 'PendingRemoval', type: 'error' }, + { + inputs: [ + { internalType: 'uint8', name: 'bits', type: 'uint8' }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + ], + name: 'SafeCastOverflowedUintDowncast', + type: 'error', + }, + { + inputs: [{ internalType: 'address', name: 'token', type: 'address' }], + name: 'SafeERC20FailedOperation', + type: 'error', + }, + { + inputs: [{ internalType: 'string', name: 'str', type: 'string' }], + name: 'StringTooLong', + type: 'error', + }, + { + inputs: [{ internalType: 'Id', name: 'id', type: 'bytes32' }], + name: 'SupplyCapExceeded', + type: 'error', + }, + { inputs: [], name: 'TimelockNotElapsed', type: 'error' }, + { + inputs: [{ internalType: 'Id', name: 'id', type: 'bytes32' }], + name: 'UnauthorizedMarket', + type: 'error', + }, + { inputs: [], name: 'ZeroAddress', type: 'error' }, + { inputs: [], name: 'ZeroFeeRecipient', type: 'error' }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'uint256', + name: 'newTotalAssets', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'feeShares', + type: 'uint256', + }, + ], + name: 'AccrueInterest', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'spender', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + ], + name: 'Approval', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'sender', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'assets', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'shares', + type: 'uint256', + }, + ], + name: 'Deposit', + type: 'event', + }, + { anonymous: false, inputs: [], name: 'EIP712DomainChanged', type: 'event' }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'previousOwner', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'newOwner', + type: 'address', + }, + ], + name: 'OwnershipTransferStarted', + 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', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'caller', + type: 'address', + }, + { indexed: true, internalType: 'Id', name: 'id', type: 'bytes32' }, + { + indexed: false, + internalType: 'uint256', + name: 'suppliedAssets', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'suppliedShares', + type: 'uint256', + }, + ], + name: 'ReallocateSupply', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'caller', + type: 'address', + }, + { indexed: true, internalType: 'Id', name: 'id', type: 'bytes32' }, + { + indexed: false, + internalType: 'uint256', + name: 'withdrawnAssets', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'withdrawnShares', + type: 'uint256', + }, + ], + name: 'ReallocateWithdraw', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'caller', + type: 'address', + }, + { indexed: true, internalType: 'Id', name: 'id', type: 'bytes32' }, + ], + name: 'RevokePendingCap', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'caller', + type: 'address', + }, + ], + name: 'RevokePendingGuardian', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'caller', + type: 'address', + }, + { indexed: true, internalType: 'Id', name: 'id', type: 'bytes32' }, + ], + name: 'RevokePendingMarketRemoval', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'caller', + type: 'address', + }, + ], + name: 'RevokePendingTimelock', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'caller', + type: 'address', + }, + { indexed: true, internalType: 'Id', name: 'id', type: 'bytes32' }, + { indexed: false, internalType: 'uint256', name: 'cap', type: 'uint256' }, + ], + name: 'SetCap', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'newCurator', + type: 'address', + }, + ], + name: 'SetCurator', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'caller', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'newFee', + type: 'uint256', + }, + ], + name: 'SetFee', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'newFeeRecipient', + type: 'address', + }, + ], + name: 'SetFeeRecipient', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'caller', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'guardian', + type: 'address', + }, + ], + name: 'SetGuardian', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'allocator', + type: 'address', + }, + { + indexed: false, + internalType: 'bool', + name: 'isAllocator', + type: 'bool', + }, + ], + name: 'SetIsAllocator', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'newSkimRecipient', + type: 'address', + }, + ], + name: 'SetSkimRecipient', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'caller', + type: 'address', + }, + { + indexed: false, + internalType: 'Id[]', + name: 'newSupplyQueue', + type: 'bytes32[]', + }, + ], + name: 'SetSupplyQueue', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'caller', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'newTimelock', + type: 'uint256', + }, + ], + name: 'SetTimelock', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'caller', + type: 'address', + }, + { + indexed: false, + internalType: 'Id[]', + name: 'newWithdrawQueue', + type: 'bytes32[]', + }, + ], + name: 'SetWithdrawQueue', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'caller', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'token', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + ], + name: 'Skim', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'caller', + type: 'address', + }, + { indexed: true, internalType: 'Id', name: 'id', type: 'bytes32' }, + { indexed: false, internalType: 'uint256', name: 'cap', type: 'uint256' }, + ], + name: 'SubmitCap', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'newGuardian', + type: 'address', + }, + ], + name: 'SubmitGuardian', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'caller', + type: 'address', + }, + { indexed: true, internalType: 'Id', name: 'id', type: 'bytes32' }, + ], + name: 'SubmitMarketRemoval', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'uint256', + name: 'newTimelock', + type: 'uint256', + }, + ], + name: 'SubmitTimelock', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'from', type: 'address' }, + { indexed: true, internalType: 'address', name: 'to', type: 'address' }, + { + indexed: false, + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + ], + name: 'Transfer', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'uint256', + name: 'updatedTotalAssets', + type: 'uint256', + }, + ], + name: 'UpdateLastTotalAssets', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'sender', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'receiver', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'assets', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'shares', + type: 'uint256', + }, + ], + name: 'Withdraw', + type: 'event', + }, + { + inputs: [], + name: 'DECIMALS_OFFSET', + outputs: [{ internalType: 'uint8', name: '', type: 'uint8' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'DOMAIN_SEPARATOR', + outputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'MORPHO', + outputs: [{ internalType: 'contract IMorpho', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + components: [ + { internalType: 'address', name: 'loanToken', type: 'address' }, + { internalType: 'address', name: 'collateralToken', type: 'address' }, + { internalType: 'address', name: 'oracle', type: 'address' }, + { internalType: 'address', name: 'irm', type: 'address' }, + { internalType: 'uint256', name: 'lltv', type: 'uint256' }, + ], + internalType: 'struct MarketParams', + name: 'marketParams', + type: 'tuple', + }, + ], + name: 'acceptCap', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'acceptGuardian', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'acceptOwnership', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'acceptTimelock', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'owner', type: 'address' }, + { internalType: 'address', name: 'spender', type: 'address' }, + ], + name: 'allowance', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'spender', type: 'address' }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + ], + name: 'approve', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'asset', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'account', type: 'address' }], + name: 'balanceOf', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'Id', name: '', type: 'bytes32' }], + name: 'config', + outputs: [ + { internalType: 'uint184', name: 'cap', type: 'uint184' }, + { internalType: 'bool', name: 'enabled', type: 'bool' }, + { internalType: 'uint64', name: 'removableAt', type: 'uint64' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'shares', type: 'uint256' }], + name: 'convertToAssets', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'assets', type: 'uint256' }], + name: 'convertToShares', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'curator', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'decimals', + outputs: [{ internalType: 'uint8', name: '', type: 'uint8' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint256', name: 'assets', type: 'uint256' }, + { internalType: 'address', name: 'receiver', type: 'address' }, + ], + name: 'deposit', + outputs: [{ internalType: 'uint256', name: 'shares', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'eip712Domain', + outputs: [ + { internalType: 'bytes1', name: 'fields', type: 'bytes1' }, + { internalType: 'string', name: 'name', type: 'string' }, + { internalType: 'string', name: 'version', type: 'string' }, + { internalType: 'uint256', name: 'chainId', type: 'uint256' }, + { internalType: 'address', name: 'verifyingContract', type: 'address' }, + { internalType: 'bytes32', name: 'salt', type: 'bytes32' }, + { internalType: 'uint256[]', name: 'extensions', type: 'uint256[]' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'fee', + outputs: [{ internalType: 'uint96', name: '', type: 'uint96' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'feeRecipient', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'guardian', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: '', type: 'address' }], + name: 'isAllocator', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'lastTotalAssets', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: '', type: 'address' }], + name: 'maxDeposit', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: '', type: 'address' }], + name: 'maxMint', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'owner', type: 'address' }], + name: 'maxRedeem', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'owner', type: 'address' }], + name: 'maxWithdraw', + outputs: [{ internalType: 'uint256', name: 'assets', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint256', name: 'shares', type: 'uint256' }, + { internalType: 'address', name: 'receiver', type: 'address' }, + ], + name: 'mint', + outputs: [{ internalType: 'uint256', name: 'assets', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes[]', name: 'data', type: 'bytes[]' }], + name: 'multicall', + outputs: [{ internalType: 'bytes[]', name: 'results', type: 'bytes[]' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'name', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'owner', type: 'address' }], + name: 'nonces', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'owner', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'Id', name: '', type: 'bytes32' }], + name: 'pendingCap', + outputs: [ + { internalType: 'uint192', name: 'value', type: 'uint192' }, + { internalType: 'uint64', name: 'validAt', type: 'uint64' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'pendingGuardian', + outputs: [ + { internalType: 'address', name: 'value', type: 'address' }, + { internalType: 'uint64', name: 'validAt', type: 'uint64' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'pendingOwner', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'pendingTimelock', + outputs: [ + { internalType: 'uint192', name: 'value', type: 'uint192' }, + { internalType: 'uint64', name: 'validAt', type: 'uint64' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'owner', type: 'address' }, + { internalType: 'address', name: 'spender', type: 'address' }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + { internalType: 'uint256', name: 'deadline', type: 'uint256' }, + { internalType: 'uint8', name: 'v', type: 'uint8' }, + { internalType: 'bytes32', name: 'r', type: 'bytes32' }, + { internalType: 'bytes32', name: 's', type: 'bytes32' }, + ], + name: 'permit', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'assets', type: 'uint256' }], + name: 'previewDeposit', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'shares', type: 'uint256' }], + name: 'previewMint', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'shares', type: 'uint256' }], + name: 'previewRedeem', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'assets', type: 'uint256' }], + name: 'previewWithdraw', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + components: [ + { internalType: 'address', name: 'loanToken', type: 'address' }, + { + internalType: 'address', + name: 'collateralToken', + type: 'address', + }, + { internalType: 'address', name: 'oracle', type: 'address' }, + { internalType: 'address', name: 'irm', type: 'address' }, + { internalType: 'uint256', name: 'lltv', type: 'uint256' }, + ], + internalType: 'struct MarketParams', + name: 'marketParams', + type: 'tuple', + }, + { internalType: 'uint256', name: 'assets', type: 'uint256' }, + ], + internalType: 'struct MarketAllocation[]', + name: 'allocations', + type: 'tuple[]', + }, + ], + name: 'reallocate', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint256', name: 'shares', type: 'uint256' }, + { internalType: 'address', name: 'receiver', type: 'address' }, + { internalType: 'address', name: 'owner', type: 'address' }, + ], + name: 'redeem', + outputs: [{ internalType: 'uint256', name: 'assets', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'renounceOwnership', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'Id', name: 'id', type: 'bytes32' }], + name: 'revokePendingCap', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'revokePendingGuardian', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'Id', name: 'id', type: 'bytes32' }], + name: 'revokePendingMarketRemoval', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'revokePendingTimelock', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newCurator', type: 'address' }], + name: 'setCurator', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'newFee', type: 'uint256' }], + name: 'setFee', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'newFeeRecipient', type: 'address' }, + ], + name: 'setFeeRecipient', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'newAllocator', type: 'address' }, + { internalType: 'bool', name: 'newIsAllocator', type: 'bool' }, + ], + name: 'setIsAllocator', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'newSkimRecipient', type: 'address' }, + ], + name: 'setSkimRecipient', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'Id[]', name: 'newSupplyQueue', type: 'bytes32[]' }, + ], + name: 'setSupplyQueue', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'token', type: 'address' }], + name: 'skim', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'skimRecipient', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + components: [ + { internalType: 'address', name: 'loanToken', type: 'address' }, + { internalType: 'address', name: 'collateralToken', type: 'address' }, + { internalType: 'address', name: 'oracle', type: 'address' }, + { internalType: 'address', name: 'irm', type: 'address' }, + { internalType: 'uint256', name: 'lltv', type: 'uint256' }, + ], + internalType: 'struct MarketParams', + name: 'marketParams', + type: 'tuple', + }, + { internalType: 'uint256', name: 'newSupplyCap', type: 'uint256' }, + ], + name: 'submitCap', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newGuardian', type: 'address' }], + name: 'submitGuardian', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { internalType: 'address', name: 'loanToken', type: 'address' }, + { internalType: 'address', name: 'collateralToken', type: 'address' }, + { internalType: 'address', name: 'oracle', type: 'address' }, + { internalType: 'address', name: 'irm', type: 'address' }, + { internalType: 'uint256', name: 'lltv', type: 'uint256' }, + ], + internalType: 'struct MarketParams', + name: 'marketParams', + type: 'tuple', + }, + ], + name: 'submitMarketRemoval', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'newTimelock', type: 'uint256' }], + name: 'submitTimelock', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + name: 'supplyQueue', + outputs: [{ internalType: 'Id', name: '', type: 'bytes32' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'supplyQueueLength', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'symbol', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'timelock', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'totalAssets', + outputs: [{ internalType: 'uint256', name: 'assets', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'totalSupply', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + ], + name: 'transfer', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'from', type: 'address' }, + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + ], + name: 'transferFrom', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newOwner', type: 'address' }], + name: 'transferOwnership', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256[]', name: 'indexes', type: 'uint256[]' }], + name: 'updateWithdrawQueue', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint256', name: 'assets', type: 'uint256' }, + { internalType: 'address', name: 'receiver', type: 'address' }, + { internalType: 'address', name: 'owner', type: 'address' }, + ], + name: 'withdraw', + outputs: [{ internalType: 'uint256', name: 'shares', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + name: 'withdrawQueue', + outputs: [{ internalType: 'Id', name: '', type: 'bytes32' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'withdrawQueueLength', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/src/earn/constants.ts b/src/earn/constants.ts index b57e488c2c..3ae9cf0958 100644 --- a/src/earn/constants.ts +++ b/src/earn/constants.ts @@ -1,26 +1,2 @@ -export const MORPHO_VAULT_ABI = [ - { - inputs: [ - { internalType: 'uint256', name: 'assets', type: 'uint256' }, - { internalType: 'address', name: 'receiver', type: 'address' }, - ], - name: 'deposit', - outputs: [{ internalType: 'uint256', name: 'shares', type: 'uint256' }], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [ - { internalType: 'uint256', name: 'assets', type: 'uint256' }, - { internalType: 'address', name: 'receiver', type: 'address' }, - { internalType: 'address', name: 'owner', type: 'address' }, - ], - name: 'withdraw', - outputs: [{ internalType: 'uint256', name: 'shares', type: 'uint256' }], - stateMutability: 'nonpayable', - type: 'function', - }, -] as const; - export const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; export const USDC_DECIMALS = 6; diff --git a/src/earn/hooks/useBuildMorphoDepositTx.test.ts b/src/earn/hooks/useBuildMorphoDepositTx.test.ts new file mode 100644 index 0000000000..ca49b70be3 --- /dev/null +++ b/src/earn/hooks/useBuildMorphoDepositTx.test.ts @@ -0,0 +1,59 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { + type UseBuildMorphoDepositTxParams, + useBuildMorphoDepositTx, +} from './useBuildMorphoDepositTx'; +import { useMorphoVault } from './useMorphoVault'; + +const DUMMY_ADDRESS = '0x9E95f497a7663B70404496dB6481c890C4825fe1' as const; + +// Mock dependencies +vi.mock('./useMorphoVault'); +vi.mock('@/earn/utils/buildDepositToMorphoTx', () => ({ + buildDepositToMorphoTx: vi + .fn() + .mockReturnValue([{ to: '0x123', data: '0x456' }]), +})); + +describe('useBuildMorphoDepositTx', () => { + const mockParams: UseBuildMorphoDepositTxParams = { + vaultAddress: DUMMY_ADDRESS, + receiverAddress: DUMMY_ADDRESS, + amount: 100, + }; + + it('returns empty calls when vault data is not available', () => { + vi.mocked(useMorphoVault).mockReturnValue({ + status: 'pending', + asset: undefined, + balance: undefined, + assetDecimals: undefined, + vaultDecimals: undefined, + name: undefined, + }); + + const { result } = renderHook(() => useBuildMorphoDepositTx(mockParams)); + + expect(result.current.calls).toEqual([]); + }); + + it('builds deposit transaction when vault data is available', () => { + const mockAsset = DUMMY_ADDRESS; + const mockDecimals = 18; + + vi.mocked(useMorphoVault).mockReturnValue({ + status: 'success', + asset: mockAsset, + balance: '1000', + assetDecimals: mockDecimals, + vaultDecimals: mockDecimals, + name: 'Mock Name', + }); + + const { result } = renderHook(() => useBuildMorphoDepositTx(mockParams)); + + expect(result.current.calls).toEqual([{ to: '0x123', data: '0x456' }]); + expect(result.current.calls).toHaveLength(1); + }); +}); diff --git a/src/earn/hooks/useBuildMorphoDepositTx.ts b/src/earn/hooks/useBuildMorphoDepositTx.ts new file mode 100644 index 0000000000..52262ce4ea --- /dev/null +++ b/src/earn/hooks/useBuildMorphoDepositTx.ts @@ -0,0 +1,46 @@ +import { useMorphoVault } from '@/earn/hooks/useMorphoVault'; +import { buildDepositToMorphoTx } from '@/earn/utils/buildDepositToMorphoTx'; +import type { Call } from '@/transaction/types'; +import { type Address, parseUnits } from 'viem'; + +export type UseBuildMorphoDepositTxParams = { + vaultAddress: Address; + receiverAddress: Address; + amount: number; +}; + +/** + * Generates Call[] for a Morpho deposit transaction + * to be used with + */ +export function useBuildMorphoDepositTx({ + vaultAddress, + receiverAddress, + amount, +}: UseBuildMorphoDepositTxParams): { + calls: Call[]; +} { + const { asset, balance, assetDecimals } = useMorphoVault({ + vaultAddress, + address: receiverAddress, + }); + + if (!asset || balance === undefined || !assetDecimals) { + return { + calls: [], + }; + } + + const parsedAmount = parseUnits(amount.toString(), assetDecimals); + + const calls = buildDepositToMorphoTx({ + receiverAddress, + vaultAddress, + tokenAddress: asset, + amount: parsedAmount, + }); + + return { + calls, + }; +} diff --git a/src/earn/hooks/useBuildMorphoWithdrawTx.test.ts b/src/earn/hooks/useBuildMorphoWithdrawTx.test.ts new file mode 100644 index 0000000000..562b1135e3 --- /dev/null +++ b/src/earn/hooks/useBuildMorphoWithdrawTx.test.ts @@ -0,0 +1,71 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { + type UseBuildMorphoWithdrawTxParams, + useBuildMorphoWithdrawTx, +} from './useBuildMorphoWithdrawTx'; +import { useMorphoVault } from './useMorphoVault'; + +const DUMMY_ADDRESS = '0x9E95f497a7663B70404496dB6481c890C4825fe1' as const; + +// Mock dependencies +vi.mock('./useMorphoVault'); +vi.mock('@/earn/utils/buildWithdrawFromMorphoTx', () => ({ + buildWithdrawFromMorphoTx: vi + .fn() + .mockReturnValue([{ to: '0x123', data: '0x456' }]), +})); + +describe('useBuildMorphoWithdrawTx', () => { + const mockParams: UseBuildMorphoWithdrawTxParams = { + vaultAddress: DUMMY_ADDRESS, + receiverAddress: DUMMY_ADDRESS, + amount: 100, + }; + + it('returns empty calls when vault data is not available', () => { + vi.mocked(useMorphoVault).mockReturnValue({ + status: 'pending', + asset: undefined, + balance: undefined, + assetDecimals: undefined, + vaultDecimals: undefined, + name: undefined, + }); + + const { result } = renderHook(() => useBuildMorphoWithdrawTx(mockParams)); + + expect(result.current.calls).toEqual([]); + }); + + it('returns empty calls when amount is greater than balance', () => { + vi.mocked(useMorphoVault).mockReturnValue({ + status: 'success', + asset: DUMMY_ADDRESS, + balance: '50', + assetDecimals: 18, + vaultDecimals: 18, + name: 'Mock Name', + }); + + const { result } = renderHook(() => useBuildMorphoWithdrawTx(mockParams)); + + expect(result.current.calls).toEqual([]); + }); + + it('builds withdraw transaction when vault data is available and amount is valid', () => { + vi.mocked(useMorphoVault).mockReturnValue({ + status: 'success', + asset: DUMMY_ADDRESS, + balance: '1000', + assetDecimals: 18, + vaultDecimals: 18, + name: 'Mock Name', + }); + + const { result } = renderHook(() => useBuildMorphoWithdrawTx(mockParams)); + + expect(result.current.calls).toEqual([{ to: '0x123', data: '0x456' }]); + expect(result.current.calls).toHaveLength(1); + }); +}); diff --git a/src/earn/hooks/useBuildMorphoWithdrawTx.ts b/src/earn/hooks/useBuildMorphoWithdrawTx.ts new file mode 100644 index 0000000000..ec0c51dc2e --- /dev/null +++ b/src/earn/hooks/useBuildMorphoWithdrawTx.ts @@ -0,0 +1,53 @@ +import { useMorphoVault } from '@/earn/hooks/useMorphoVault'; +import { buildWithdrawFromMorphoTx } from '@/earn/utils/buildWithdrawFromMorphoTx'; +import type { Call } from '@/transaction/types'; +import { type Address, parseUnits } from 'viem'; + +export type UseBuildMorphoWithdrawTxParams = { + vaultAddress: Address; + receiverAddress: Address; + amount: number; +}; + +/** + * Generates Call[] for a Morpho withdraw transaction + * to be used with + */ +export function useBuildMorphoWithdrawTx({ + vaultAddress, + amount, + receiverAddress, +}: UseBuildMorphoWithdrawTxParams): { + calls: Call[]; +} { + const { asset, balance, assetDecimals, vaultDecimals } = useMorphoVault({ + vaultAddress, + address: receiverAddress, + }); + + const amountIsGreaterThanBalance = amount > Number(balance); + + if ( + !asset || + balance === undefined || + !assetDecimals || + !vaultDecimals || + amountIsGreaterThanBalance + ) { + return { + calls: [], + }; + } + + const parsedAmount = parseUnits(amount.toString(), assetDecimals); + + const calls = buildWithdrawFromMorphoTx({ + receiverAddress, + vaultAddress, + amount: parsedAmount, + }); + + return { + calls, + }; +} diff --git a/src/earn/hooks/useMorphoVault.test.ts b/src/earn/hooks/useMorphoVault.test.ts new file mode 100644 index 0000000000..c597dc23bb --- /dev/null +++ b/src/earn/hooks/useMorphoVault.test.ts @@ -0,0 +1,90 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { + type UseReadContractReturnType, + type UseReadContractsReturnType, + useReadContract, + useReadContracts, +} from 'wagmi'; +import { useMorphoVault } from './useMorphoVault'; + +const DUMMY_ADDRESS = '0x9E95f497a7663B70404496dB6481c890C4825fe1' as const; + +// Mock dependencies +vi.mock('wagmi', () => ({ + useReadContract: vi.fn(), + useReadContracts: vi.fn(), +})); + +describe('useMorphoVault', () => { + const mockParams = { + vaultAddress: DUMMY_ADDRESS, + address: DUMMY_ADDRESS, + }; + + it('returns undefined values when contract reads are pending', () => { + vi.mocked(useReadContracts).mockReturnValue({ + data: undefined, + status: 'pending', + } as UseReadContractsReturnType); // for brevity + vi.mocked(useReadContract).mockReturnValue({ + data: undefined, + } as UseReadContractReturnType); // for brevity + + const { result } = renderHook(() => useMorphoVault(mockParams)); + + expect(result.current).toEqual({ + status: 'pending', + asset: undefined, + assetDecimals: undefined, + vaultDecimals: undefined, + name: undefined, + balance: undefined, + }); + }); + + it('returns formatted data when contract reads are successful', () => { + vi.mocked(useReadContracts).mockReturnValue({ + data: [ + { result: DUMMY_ADDRESS }, // asset + { result: 'Morpho Vault' }, // name + { result: 1000000000000000000n }, // balanceOf + { result: 18 }, // decimals + ], + status: 'success', + } as UseReadContractsReturnType); // for brevity + vi.mocked(useReadContract).mockReturnValue({ + data: 18, + } as UseReadContractReturnType); // for brevity + + const { result } = renderHook(() => useMorphoVault(mockParams)); + + expect(result.current).toEqual({ + status: 'success', + asset: DUMMY_ADDRESS, + assetDecimals: 18, + vaultDecimals: 18, + name: 'Morpho Vault', + balance: '1', + }); + }); + + it('handles missing balance data', () => { + vi.mocked(useReadContracts).mockReturnValue({ + data: [ + { result: DUMMY_ADDRESS }, + { result: 'Morpho Vault' }, + { result: undefined }, // missing balance + { result: 18 }, + ], + status: 'success', + } as UseReadContractsReturnType); // for brevity + vi.mocked(useReadContract).mockReturnValue({ + data: 18, + } as UseReadContractReturnType); // for brevity + + const { result } = renderHook(() => useMorphoVault(mockParams)); + + expect(result.current.balance).toBeUndefined(); + }); +}); diff --git a/src/earn/hooks/useMorphoVault.ts b/src/earn/hooks/useMorphoVault.ts new file mode 100644 index 0000000000..2b7c56a059 --- /dev/null +++ b/src/earn/hooks/useMorphoVault.ts @@ -0,0 +1,68 @@ +import { MORPHO_VAULT_ABI } from '@/earn/abis/morpho'; +import { type Address, erc20Abi, formatUnits } from 'viem'; +import { useReadContract, useReadContracts } from 'wagmi'; + +type UseMorphoVaultParams = { + vaultAddress: Address; + address: Address; +}; + +export type UseMorphoVaultReturnType = { + status: 'pending' | 'success' | 'error'; + asset: Address | undefined; + assetDecimals: number | undefined; + vaultDecimals: number | undefined; + name: string | undefined; + balance: string | undefined; +}; + +export function useMorphoVault({ + vaultAddress, + address, +}: UseMorphoVaultParams): UseMorphoVaultReturnType { + const { data, status } = useReadContracts({ + contracts: [ + { + abi: MORPHO_VAULT_ABI, + address: vaultAddress, + functionName: 'asset', + }, + { + abi: MORPHO_VAULT_ABI, + address: vaultAddress, + functionName: 'name', + }, + { + abi: MORPHO_VAULT_ABI, + address: vaultAddress, + functionName: 'balanceOf', + args: [address], + }, + { + abi: MORPHO_VAULT_ABI, + address: vaultAddress, + functionName: 'decimals', + }, + ], + }); + + const { data: tokenDecimals } = useReadContract({ + abi: erc20Abi, + address: data?.[0].result, + functionName: 'decimals', + }); + + const formattedBalance = + data?.[2].result && data?.[3].result + ? formatUnits(data?.[2].result, data?.[3].result) + : undefined; + + return { + status, + asset: data?.[0].result, + assetDecimals: tokenDecimals, + vaultDecimals: data?.[3].result, + name: data?.[1].result, + balance: formattedBalance, + }; +} diff --git a/src/earn/utils/buildDepositToMorphoTx.test.ts b/src/earn/utils/buildDepositToMorphoTx.test.ts index 15698b43d0..0ebd4d9825 100644 --- a/src/earn/utils/buildDepositToMorphoTx.test.ts +++ b/src/earn/utils/buildDepositToMorphoTx.test.ts @@ -1,8 +1,5 @@ -import { - MORPHO_VAULT_ABI, - USDC_ADDRESS, - USDC_DECIMALS, -} from '@/earn/constants'; +import { MORPHO_VAULT_ABI } from '@/earn/abis/morpho'; +import { USDC_ADDRESS, USDC_DECIMALS } from '@/earn/constants'; import { encodeFunctionData, parseUnits } from 'viem'; import { describe, expect, it } from 'vitest'; import { diff --git a/src/earn/utils/buildDepositToMorphoTx.ts b/src/earn/utils/buildDepositToMorphoTx.ts index 2e4e2578fd..5e7a17ab47 100644 --- a/src/earn/utils/buildDepositToMorphoTx.ts +++ b/src/earn/utils/buildDepositToMorphoTx.ts @@ -1,4 +1,4 @@ -import { MORPHO_VAULT_ABI } from '@/earn/constants'; +import { MORPHO_VAULT_ABI } from '@/earn/abis/morpho'; import type { Call } from '@/transaction/types'; import { type Address, encodeFunctionData, erc20Abi } from 'viem'; diff --git a/src/earn/utils/buildWithdrawFromMorphoTx.test.ts b/src/earn/utils/buildWithdrawFromMorphoTx.test.ts index cea719d1e5..4a745308fd 100644 --- a/src/earn/utils/buildWithdrawFromMorphoTx.test.ts +++ b/src/earn/utils/buildWithdrawFromMorphoTx.test.ts @@ -1,4 +1,5 @@ -import { MORPHO_VAULT_ABI, USDC_DECIMALS } from '@/earn/constants'; +import { MORPHO_VAULT_ABI } from '@/earn/abis/morpho'; +import { USDC_DECIMALS } from '@/earn/constants'; import { encodeFunctionData, parseUnits } from 'viem'; import { describe, expect, it } from 'vitest'; import { diff --git a/src/earn/utils/buildWithdrawFromMorphoTx.ts b/src/earn/utils/buildWithdrawFromMorphoTx.ts index 82e768c53e..16e792d1bc 100644 --- a/src/earn/utils/buildWithdrawFromMorphoTx.ts +++ b/src/earn/utils/buildWithdrawFromMorphoTx.ts @@ -1,4 +1,4 @@ -import { MORPHO_VAULT_ABI } from '@/earn/constants'; +import { MORPHO_VAULT_ABI } from '@/earn/abis/morpho'; import type { Call } from '@/transaction/types'; import { type Address, encodeFunctionData } from 'viem'; @@ -11,11 +11,11 @@ export type WithdrawFromMorphoArgs = { receiverAddress: Address; }; -export async function buildWithdrawFromMorphoTx({ +export function buildWithdrawFromMorphoTx({ vaultAddress, amount, receiverAddress, -}: WithdrawFromMorphoArgs): Promise { +}: WithdrawFromMorphoArgs): Call[] { const withdrawTxData = encodeFunctionData({ abi: MORPHO_VAULT_ABI, functionName: 'withdraw', diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index 2a08cd7146..344ba2103e 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -54,10 +54,17 @@ export const FundCardAmountInput = ({ updateInputWidth(); }, [value, updateInputWidth]); - // biome-ignore lint/correctness/useExhaustiveDependencies: We only want to focus the input when the input type changes + const selectedInputTypeRef = useRef(selectedInputType); + useEffect(() => { - // focus the input when the input type changes - handleFocusInput(); + /** + * We need to focus the input when the input type changes + * but not on the initial render. + */ + if (selectedInputTypeRef.current !== selectedInputType) { + selectedInputTypeRef.current = selectedInputType; + handleFocusInput(); + } }, [selectedInputType]); const handleFocusInput = () => { diff --git a/src/fund/index.ts b/src/fund/index.ts index b6149c17c6..d73169fd6f 100644 --- a/src/fund/index.ts +++ b/src/fund/index.ts @@ -2,7 +2,13 @@ // Components export { FundButton } from './components/FundButton'; export { FundCard } from './components/FundCard'; +export { FundCardAmountInput } from './components/FundCardAmountInput'; +export { FundCardAmountInputTypeSwitch } from './components/FundCardAmountInputTypeSwitch'; +export { FundCardHeader } from './components/FundCardHeader'; +export { FundCardPaymentMethodDropdown } from './components/FundCardPaymentMethodDropdown'; +export { FundCardPresetAmountInputList } from './components/FundCardPresetAmountInputList'; export { FundCardProvider } from './components/FundCardProvider'; +export { FundCardSubmitButton } from './components/FundCardSubmitButton'; // Utils export { fetchOnrampConfig } from './utils/fetchOnrampConfig'; @@ -13,6 +19,9 @@ export { getCoinbaseSmartWalletFundUrl } from './utils/getCoinbaseSmartWalletFun export { getOnrampBuyUrl } from './utils/getOnrampBuyUrl'; export { setupOnrampEventListeners } from './utils/setupOnrampEventListeners'; +// Hooks +export { useFundContext } from './components/FundCardProvider'; + // Types export type { EventMetadata, diff --git a/src/internal/primitives/Dialog.tsx b/src/internal/primitives/Dialog.tsx index e60d9a65d4..bfda47ad41 100644 --- a/src/internal/primitives/Dialog.tsx +++ b/src/internal/primitives/Dialog.tsx @@ -51,10 +51,11 @@ export function Dialog({
e.stopPropagation()} onKeyDown={(e: React.KeyboardEvent) => { @@ -64,7 +65,6 @@ export function Dialog({ }} ref={dialogRef} role="dialog" - className="zoom-in-95 animate-in duration-200" > {children}
diff --git a/src/internal/primitives/DismissableLayer.test.tsx b/src/internal/primitives/DismissableLayer.test.tsx index abf55879c5..4fd103ac8f 100644 --- a/src/internal/primitives/DismissableLayer.test.tsx +++ b/src/internal/primitives/DismissableLayer.test.tsx @@ -106,4 +106,21 @@ describe('DismissableLayer', () => { fireEvent.keyDown(document, { key: 'Escape' }); fireEvent.pointerDown(document.body); }); + + it('does not call onDismiss when clicking the trigger button', () => { + render( + <> + + +
Test Content
+
+ , + ); + + const triggerButton = screen.getByLabelText('Toggle swap settings'); + fireEvent.pointerDown(triggerButton); + expect(onDismiss).not.toHaveBeenCalled(); + }); }); diff --git a/src/internal/primitives/DismissableLayer.tsx b/src/internal/primitives/DismissableLayer.tsx index c6a1aadf52..62c35f3513 100644 --- a/src/internal/primitives/DismissableLayer.tsx +++ b/src/internal/primitives/DismissableLayer.tsx @@ -16,8 +16,6 @@ export function DismissableLayer({ onDismiss, }: DismissableLayerProps) { const layerRef = useRef(null); - // Tracks whether the pointer event originated inside the React component tree - const isPointerInsideReactTreeRef = useRef(false); useEffect(() => { if (disableOutsideClick && disableEscapeKey) { @@ -30,24 +28,30 @@ export function DismissableLayer({ } }; - const shouldDismiss = (target: Node) => { - return layerRef.current && !layerRef.current.contains(target); - }; - - // Handle clicks outside the layer const handlePointerDown = (event: PointerEvent) => { - // Skip if outside clicks are disabled or if the click started inside the component - if (disableOutsideClick || isPointerInsideReactTreeRef.current) { - isPointerInsideReactTreeRef.current = false; + if (disableOutsideClick) { return; } - // Dismiss if click is outside the layer - if (shouldDismiss(event.target as Node)) { - onDismiss?.(); + // If the click is inside the dismissable layer content, don't dismiss + // This prevents the popover from closing when clicking inside it + if (layerRef.current?.contains(event.target as Node)) { + return; } - // Reset the flag after handling the event - isPointerInsideReactTreeRef.current = false; + + // Handling for the trigger button (e.g., settings toggle) + // Without this, clicking the trigger would cause both: + // 1. The button's onClick to fire (toggling isOpen) + // 2. This dismissal logic to fire (forcing close) + // This would create a race condition where the popover rapidly closes and reopens + const isTriggerClick = (event.target as HTMLElement).closest( + '[aria-label="Toggle swap settings"]', + ); + if (isTriggerClick) { + return; + } + + onDismiss?.(); }; document.addEventListener('keydown', handleKeyDown); @@ -60,15 +64,7 @@ export function DismissableLayer({ }, [disableOutsideClick, disableEscapeKey, onDismiss]); return ( -
{ - isPointerInsideReactTreeRef.current = true; - }} - ref={layerRef} - > +
{children}
); diff --git a/src/internal/primitives/Popover.test.tsx b/src/internal/primitives/Popover.test.tsx new file mode 100644 index 0000000000..0c847bfb83 --- /dev/null +++ b/src/internal/primitives/Popover.test.tsx @@ -0,0 +1,224 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Popover } from './Popover'; + +describe('Popover', () => { + let anchorEl: HTMLElement; + + beforeEach(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + + anchorEl = document.createElement('button'); + anchorEl.setAttribute('data-testid', 'anchor'); + document.body.appendChild(anchorEl); + }); + + afterEach(() => { + cleanup(); + document.body.innerHTML = ''; + vi.clearAllMocks(); + }); + + describe('rendering', () => { + it('should not render when isOpen is false', () => { + render( + + Content + , + ); + + expect(screen.queryByTestId('ockPopover')).not.toBeInTheDocument(); + }); + + it('should render when isOpen is true', () => { + render( + + Content + , + ); + + expect(screen.getByTestId('ockPopover')).toBeInTheDocument(); + expect(screen.getByText('Content')).toBeInTheDocument(); + }); + + it('should handle null anchorEl gracefully', () => { + render( + + Content + , + ); + + expect(screen.getByTestId('ockPopover')).toBeInTheDocument(); + }); + }); + + describe('positioning', () => { + const positions = ['top', 'right', 'bottom', 'left'] as const; + const alignments = ['start', 'center', 'end'] as const; + + for (const position of positions) { + for (const align of alignments) { + it(`should position correctly with position=${position} and align=${align}`, () => { + render( + + Content + , + ); + + const popover = screen.getByTestId('ockPopover'); + expect(popover).toBeInTheDocument(); + + expect(popover.style.top).toBeDefined(); + expect(popover.style.left).toBeDefined(); + }); + } + } + + it('should update position on window resize', async () => { + render( + + Content + , + ); + + fireEvent(window, new Event('resize')); + + expect(screen.getByTestId('ockPopover')).toBeInTheDocument(); + }); + + it('should update position on scroll', async () => { + render( + + Content + , + ); + + fireEvent.scroll(window); + + expect(screen.getByTestId('ockPopover')).toBeInTheDocument(); + }); + + it('should handle missing getBoundingClientRect gracefully', () => { + const originalGetBoundingClientRect = + Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = vi + .fn() + .mockReturnValue(undefined); + + render( + + Content + , + ); + + const popover = screen.getByTestId('ockPopover'); + expect(popover).toBeInTheDocument(); + + Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; + }); + }); + + describe('interactions', () => { + it('should not call onClose when clicking inside', async () => { + const onClose = vi.fn(); + render( + + Content + , + ); + + fireEvent.mouseDown(screen.getByText('Content')); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('should call onClose when pressing Escape', async () => { + const onClose = vi.fn(); + render( + + Content + , + ); + + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); + }); + + describe('accessibility', () => { + it('should have correct ARIA attributes', () => { + render( + + Content + , + ); + + const popover = screen.getByTestId('ockPopover'); + expect(popover).toHaveAttribute('role', 'dialog'); + expect(popover).toHaveAttribute('aria-label', 'Test Label'); + expect(popover).toHaveAttribute('aria-labelledby', 'labelId'); + expect(popover).toHaveAttribute('aria-describedby', 'describeId'); + }); + + it('should trap focus when open', async () => { + const user = userEvent.setup(); + render( + + + + , + ); + + const firstButton = screen.getByText('First'); + const secondButton = screen.getByText('Second'); + + firstButton.focus(); + expect(document.activeElement).toBe(firstButton); + + await user.tab(); + expect(document.activeElement).toBe(secondButton); + + await user.tab(); + expect(document.activeElement).toBe(firstButton); + }); + }); + + describe('cleanup', () => { + it('should remove event listeners on unmount', () => { + const { unmount } = render( + + Content + , + ); + + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/internal/primitives/Popover.tsx b/src/internal/primitives/Popover.tsx new file mode 100644 index 0000000000..a1b82fc60c --- /dev/null +++ b/src/internal/primitives/Popover.tsx @@ -0,0 +1,191 @@ +import type React from 'react'; +import { useCallback, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { useTheme } from '../../core-react/internal/hooks/useTheme'; +import { cn } from '../../styles/theme'; +import { DismissableLayer } from './DismissableLayer'; +import { FocusTrap } from './FocusTrap'; + +type Position = 'top' | 'right' | 'bottom' | 'left'; +type Alignment = 'start' | 'center' | 'end'; + +type PopoverProps = { + align?: Alignment; // Determines how the popover aligns with the anchor + anchorEl: HTMLElement | null; + children?: React.ReactNode; + onClose?: () => void; + offset?: number; // Spacing (in pixels) between the anchor element and the popover content. + position?: Position; // Determines which side of the anchor element the popover will appear. + isOpen?: boolean; + 'aria-label'?: string; + 'aria-labelledby'?: string; + 'aria-describedby'?: string; +}; + +/** + * Calculates the initial position of the popover based on the position prop. + */ +function getInitialPosition( + triggerRect: DOMRect, + contentRect: DOMRect, + position: Position, + offset: number, +): { top: number; left: number } { + let top = 0; + let left = 0; + + switch (position) { + case 'top': + top = triggerRect.top - contentRect.height - offset; + break; + case 'bottom': + top = triggerRect.bottom + offset; + break; + case 'left': + left = triggerRect.left - contentRect.width - offset; + break; + case 'right': + left = triggerRect.right + offset; + break; + } + + return { top, left }; +} + +/** + * Adjusts the initial position based on the alignment prop. + */ +function adjustAlignment( + triggerRect: DOMRect, + contentRect: DOMRect, + initialPosition: { top: number; left: number }, + align: Alignment, + position: Position, +): { top: number; left: number } { + const { top: initialTop, left: initialLeft } = initialPosition; + let top = initialTop; + let left = initialLeft; + + const isVerticalPosition = position === 'top' || position === 'bottom'; + + switch (align) { + case 'start': + if (isVerticalPosition) { + left = triggerRect.left; + } else { + top = triggerRect.top; + } + break; + case 'center': + if (isVerticalPosition) { + left = triggerRect.left + (triggerRect.width - contentRect.width) / 2; + } else { + top = triggerRect.top + (triggerRect.height - contentRect.height) / 2; + } + break; + case 'end': + if (isVerticalPosition) { + left = triggerRect.right - contentRect.width; + } else { + top = triggerRect.bottom - contentRect.height; + } + break; + } + + return { top, left }; +} + +/** + * Popover primitive that handles: + * - Positioning relative to anchor element + * - Focus management + * - Click outside and escape key dismissal + * - Portal rendering + * - Proper ARIA attributes + */ +export function Popover({ + children, + anchorEl, + isOpen, + onClose, + position = 'bottom', + align = 'center', + offset = 8, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-describedby': ariaDescribedby, +}: PopoverProps) { + const contentRef = useRef(null); + const componentTheme = useTheme(); + + const updatePosition = useCallback(() => { + if (!anchorEl || !contentRef.current) { + return; + } + + const triggerRect = anchorEl.getBoundingClientRect(); + const contentRect = contentRef.current?.getBoundingClientRect(); + + if (!triggerRect || !contentRect) { + return; + } + + const initialPosition = getInitialPosition( + triggerRect, + contentRect, + position, + offset, + ); + const finalPosition = adjustAlignment( + triggerRect, + contentRect, + initialPosition, + align, + position, + ); + + contentRef.current.style.top = `${finalPosition.top}px`; + contentRef.current.style.left = `${finalPosition.left}px`; + }, [anchorEl, position, offset, align]); + + useEffect(() => { + if (!isOpen) { + return; + } + + updatePosition(); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition); + + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition); + }; + }, [isOpen, updatePosition]); + + if (!isOpen) { + return null; + } + + const popover = ( +
+ + +
+ {children} +
+
+
+
+ ); + + return createPortal(popover, document.body); +} diff --git a/src/swap/constants.ts b/src/swap/constants.ts index e3f10af11b..b78d3f693e 100644 --- a/src/swap/constants.ts +++ b/src/swap/constants.ts @@ -11,6 +11,8 @@ export const UNCAUGHT_SWAP_ERROR_CODE = 'UNCAUGHT_SWAP_ERROR'; export const UNIVERSALROUTER_CONTRACT_ADDRESS = '0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD'; export const USER_REJECTED_ERROR_CODE = 'USER_REJECTED'; +export const UNSUPPORTED_AMOUNT_REFERENCE_ERROR_CODE = + 'UNSUPPORTED_AMOUNT_REFERENCE_ERROR'; export enum SwapMessage { BALANCE_ERROR = 'Error fetching token balance', CONFIRM_IN_WALLET = 'Confirm in wallet', @@ -22,6 +24,7 @@ export enum SwapMessage { SWAP_IN_PROGRESS = 'Swap in progress...', TOO_MANY_REQUESTS = 'Too many requests. Please try again later.', USER_REJECTED = 'User rejected the transaction', + UNSUPPORTED_AMOUNT_REFERENCE = 'useAggregator does not support amountReference: to, please use useAggregator: false', } export const ONRAMP_PAYMENT_METHODS = [ diff --git a/src/version.ts b/src/version.ts index 369622e4cd..0119157e60 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const version = '0.36.7'; +export const version = '0.36.8';