Skip to content

Commit

Permalink
Merge branch 'main' into ONRAMP-4917
Browse files Browse the repository at this point in the history
  • Loading branch information
rustam-cb committed Jan 31, 2025
2 parents 3d9afe4 + 3c79bd2 commit d77f4b7
Show file tree
Hide file tree
Showing 11 changed files with 435 additions and 35 deletions.
5 changes: 5 additions & 0 deletions .changeset/plenty-bananas-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/onchainkit": patch
---

Added throttling to the exchange rate fetch for the Fund Card
2 changes: 1 addition & 1 deletion playground/nextjs-app-router/onchainkit/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/onchainkit",
"version": "0.36.8",
"version": "0.36.9",
"type": "module",
"repository": "https://github.com/coinbase/onchainkit.git",
"license": "MIT",
Expand Down
53 changes: 53 additions & 0 deletions site/docs/pages/api/get-portfolios.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# `getPortfolios`

The `getPortfolios` function returns an object containing an array of
portfolios for the provided addresses. Each portfolio is an object with the address
of the wallet, the fiat value of the portfolio, and an array of tokens held by the
provided address.

:::info
Before using this endpoint, make sure to obtain a [Client API Key](https://portal.cdp.coinbase.com/projects/api-keys/client-key)
from Coinbase Developer Platform.
:::

## Usage

:::code-group

```tsx twoslash [code]
import { setOnchainKitConfig } from '@coinbase/onchainkit';
import { getPortfolios } from '@coinbase/onchainkit/api';

const response = await getPortfolios({
addresses: ['0x...'],
});
```

```json [return value]
"portfolios": [
{
"address": "0x...",
"portfolioBalanceInUsd": 100,
"tokenBalances": [{
"address": "0x...",
"chainId": 1,
"decimals": 18,
"image": "https://example.com/image.png",
"name": "Token Name",
"symbol": "TKN",
"cryptoBalance": 10,
"fiatBalance": 100
}]
}
]
```

:::

## Returns

[`Promise<GetPortfoliosResponse>`](/api/types#getportfoliosresponse)

## Parameters

[`GetPortfoliosParams`](/api/types#getportfoliosparams)
18 changes: 17 additions & 1 deletion site/docs/pages/api/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ type GetTokensResponse = Token[] | APIError;

## `GetTokenDetailsParams`

```ts
```ts
type GetTokenDetailsParams = {
contractAddress: Address;
tokenId?: string;
Expand Down Expand Up @@ -141,3 +141,19 @@ type BuildMintTransactionParams = {
```ts
type BuildMintTransactionResponse = MintTransaction | APIError;
```

## `GetPortfolioParams`

```ts
type GetPortfolioParams = {
addresses: Address[] | null | undefined;
};
```

## `GetPortfoliosResponse`

```ts
type GetPortfoliosResponse = {
portfolios: Portfolio[];
};
```
1 change: 0 additions & 1 deletion site/docs/pages/wallet/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ type ConnectWalletReact = {
children?: React.ReactNode; // Children can be utilized to display customized content when the wallet is connected.
className?: string; // Optional className override for button element
text?: string; // Optional text override for button. Note: Prefer using `ConnectWalletText` component instead as this will be deprecated in a future version.
withWalletAggregator?: boolean; // Optional flag to enable the wallet aggregator like RainbowKit
onConnect?: () => void; // Optional callback function that is called when the wallet is connected. Can be used to trigger SIWE prompts or other actions.
};
```
Expand Down
38 changes: 37 additions & 1 deletion src/fund/components/FundCardAmountInput.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { AmountInput } from '@/internal/components/amount-input/AmountInput';
import { useThrottle } from '@/internal/hooks/useThrottle';
import { useCallback } from 'react';
import { useOnrampExchangeRate } from '../hooks/useOnrampExchangeRate';
import type { FundCardAmountInputPropsReact } from '../types';
import { useFundContext } from './FundCardProvider';

const THROTTLE_DELAY_MS = 5000;

export const FundCardAmountInput = ({
className,
}: FundCardAmountInputPropsReact) => {
Expand All @@ -14,8 +19,39 @@ export const FundCardAmountInput = ({
exchangeRate,
setFundAmountFiat,
setFundAmountCrypto,
country,
subdivision,
setExchangeRate,
onError,
} = useFundContext();

const { fetchExchangeRate } = useOnrampExchangeRate({
asset,
currency,
country,
subdivision,
setExchangeRate,
onError,
});

const throttledFetchExchangeRate = useThrottle(
fetchExchangeRate,
THROTTLE_DELAY_MS,
);

/**
* Handle amount changes with throttled updates
*
* Both setFiatAmount and setCryptoAmount on the AmountInput component are called with the new amount so we only need to fetch exchange rate when either is called.
*/
const handleFiatAmountChange = useCallback(
(amount: string) => {
setFundAmountFiat(amount);
throttledFetchExchangeRate();
},
[setFundAmountFiat, throttledFetchExchangeRate],
);

return (
<AmountInput
fiatAmount={fundAmountFiat}
Expand All @@ -24,7 +60,7 @@ export const FundCardAmountInput = ({
selectedInputType={selectedInputType}
currency={currency}
className={className}
setFiatAmount={setFundAmountFiat}
setFiatAmount={handleFiatAmountChange}
setCryptoAmount={setFundAmountCrypto}
exchangeRate={String(exchangeRate)}
/>
Expand Down
49 changes: 18 additions & 31 deletions src/fund/components/FundCardProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ import {
} from 'react';
import { useValue } from '../../internal/hooks/useValue';
import { useEmitLifecycleStatus } from '../hooks/useEmitLifecycleStatus';
import { useOnrampExchangeRate } from '../hooks/useOnrampExchangeRate';
import { usePaymentMethods } from '../hooks/usePaymentMethods';
import type {
AmountInputType,
FundButtonStateReact,
FundCardProviderReact,
LifecycleStatus,
OnrampError,
PaymentMethod,
PresetAmountInputs,
} from '../types';
import { fetchOnrampQuote } from '../utils/fetchOnrampQuote';

type FundCardContextType = {
asset: string;
Expand Down Expand Up @@ -51,6 +52,7 @@ type FundCardContextType = {
updateLifecycleStatus: (
newStatus: LifecycleStatusUpdate<LifecycleStatus>,
) => void;
onError?: (error: OnrampError) => void;
};

const FundContext = createContext<FundCardContextType | undefined>(undefined);
Expand Down Expand Up @@ -92,40 +94,24 @@ export function FundCardProvider({
onStatus,
});

const fetchExchangeRate = useCallback(async () => {
setExchangeRateLoading(true);

try {
const quote = await fetchOnrampQuote({
purchaseCurrency: asset,
paymentCurrency: currency,
paymentAmount: '100',
paymentMethod: 'CARD',
country,
subdivision,
});
const { fetchExchangeRate } = useOnrampExchangeRate({
asset,
currency,
country,
subdivision,
setExchangeRate,
onError,
});

setExchangeRate(
Number(quote.purchaseAmount.value) /
Number(quote.paymentSubtotal.value),
);
} catch (err) {
if (err instanceof Error) {
console.error('Error fetching exchange rate:', err);
onError?.({
errorType: 'handled_error',
code: 'EXCHANGE_RATE_ERROR',
debugMessage: err.message,
});
}
} finally {
setExchangeRateLoading(false);
}
}, [asset, country, subdivision, currency, onError]);
const handleFetchExchangeRate = useCallback(async () => {
setExchangeRateLoading(true);
await fetchExchangeRate();
setExchangeRateLoading(false);
}, [fetchExchangeRate]);

// biome-ignore lint/correctness/useExhaustiveDependencies: One time effect
useEffect(() => {
fetchExchangeRate();
handleFetchExchangeRate();
}, []);

// Fetches and sets the payment methods to the context
Expand Down Expand Up @@ -166,6 +152,7 @@ export function FundCardProvider({
lifecycleStatus,
updateLifecycleStatus,
presetAmountInputs,
onError,
});
return <FundContext.Provider value={value}>{children}</FundContext.Provider>;
}
Expand Down
108 changes: 108 additions & 0 deletions src/fund/hooks/useOnrampExchangeRate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { renderHook } from '@testing-library/react';
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import { quoteResponseDataMock } from '../mocks';
import type { OnrampError } from '../types';
import { fetchOnrampQuote } from '../utils/fetchOnrampQuote';
import { useOnrampExchangeRate } from './useOnrampExchangeRate';

vi.mock('../utils/fetchOnrampQuote');

describe('useOnrampExchangeRate', () => {
const mockSetExchangeRate = vi.fn();
const mockOnError = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
(fetchOnrampQuote as Mock).mockResolvedValue(quoteResponseDataMock);
});

it('fetches and calculates exchange rate correctly', async () => {
const { result } = renderHook(() =>
useOnrampExchangeRate({
asset: 'ETH',
currency: 'USD',
country: 'US',
setExchangeRate: mockSetExchangeRate,
}),
);

await result.current.fetchExchangeRate();

// Verify exchange rate calculation
expect(mockSetExchangeRate).toHaveBeenCalledWith(
Number(quoteResponseDataMock.purchaseAmount.value) /
Number(quoteResponseDataMock.paymentSubtotal.value),
);
});

it('handles API errors', async () => {
const error = new Error('API Error');
(fetchOnrampQuote as Mock).mockRejectedValue(error);

const { result } = renderHook(() =>
useOnrampExchangeRate({
asset: 'ETH',
currency: 'USD',
country: 'US',
setExchangeRate: mockSetExchangeRate,
onError: mockOnError,
}),
);

await result.current.fetchExchangeRate();

// Should call onError with correct error object
expect(mockOnError).toHaveBeenCalledWith({
errorType: 'handled_error',
code: 'EXCHANGE_RATE_ERROR',
debugMessage: 'API Error',
} satisfies OnrampError);
});

it('includes subdivision in API call when provided', async () => {
const { result } = renderHook(() =>
useOnrampExchangeRate({
asset: 'ETH',
currency: 'USD',
country: 'US',
subdivision: 'CA',
setExchangeRate: mockSetExchangeRate,
}),
);

await result.current.fetchExchangeRate();

expect(fetchOnrampQuote).toHaveBeenCalledWith({
purchaseCurrency: 'ETH',
paymentCurrency: 'USD',
paymentAmount: '100',
paymentMethod: 'CARD',
country: 'US',
subdivision: 'CA',
});
});

it('handles unknown errors', async () => {
const error = { someField: 'unexpected error' };
(fetchOnrampQuote as Mock).mockRejectedValue(error);

const { result } = renderHook(() =>
useOnrampExchangeRate({
asset: 'ETH',
currency: 'USD',
country: 'US',
setExchangeRate: mockSetExchangeRate,
onError: mockOnError,
}),
);

await result.current.fetchExchangeRate();

// Should call onError with correct error object
expect(mockOnError).toHaveBeenCalledWith({
errorType: 'unknown_error',
code: 'EXCHANGE_RATE_ERROR',
debugMessage: JSON.stringify(error),
} satisfies OnrampError);
});
});
Loading

0 comments on commit d77f4b7

Please sign in to comment.