diff --git a/packages/common/src/utils/TypeUtil.ts b/packages/common/src/utils/TypeUtil.ts index d723cbb3c8..9a199626dd 100644 --- a/packages/common/src/utils/TypeUtil.ts +++ b/packages/common/src/utils/TypeUtil.ts @@ -26,6 +26,7 @@ export interface TransactionMetadata { sentTo: string status: TransactionStatus | CoinbaseTransactionStatus nonce: number + chain?: string } export interface TransactionTransfer { diff --git a/packages/core/src/controllers/OnRampController.ts b/packages/core/src/controllers/OnRampController.ts index bdaf4880b8..bc18ccde84 100644 --- a/packages/core/src/controllers/OnRampController.ts +++ b/packages/core/src/controllers/OnRampController.ts @@ -30,7 +30,7 @@ export interface OnRampControllerState { type StateKey = keyof OnRampControllerState -const USDC_CURRENCY_DEFAULT = { +export const USDC_CURRENCY_DEFAULT = { id: '2b92315d-eab7-5bef-84fa-089a131333f5', name: 'USD Coin', symbol: 'USDC', @@ -50,7 +50,7 @@ const USDC_CURRENCY_DEFAULT = { ] } -const USD_CURRENCY_DEFAULT = { +export const USD_CURRENCY_DEFAULT = { id: 'USD', payment_method_limits: [ { @@ -66,8 +66,7 @@ const USD_CURRENCY_DEFAULT = { ] } -// -- State --------------------------------------------- // -const state = proxy({ +const defaultState = { providers: ONRAMP_PROVIDERS as OnRampProvider[], selectedProvider: null, error: null, @@ -76,7 +75,10 @@ const state = proxy({ purchaseCurrencies: [USDC_CURRENCY_DEFAULT], paymentCurrencies: [], quotesLoading: false -}) +} + +// -- State --------------------------------------------- // +const state = proxy(defaultState) // -- Controller ---------------------------------------- // export const OnRampController = { @@ -124,15 +126,37 @@ export const OnRampController = { async getQuote() { state.quotesLoading = true - const quote = await BlockchainApiController.getOnrampQuote({ - purchaseCurrency: state.purchaseCurrency, - paymentCurrency: state.paymentCurrency, - amount: state.paymentAmount?.toString() || '0', - network: state.purchaseCurrency?.name - }) - state.quotesLoading = false - state.purchaseAmount = Number(quote.purchaseAmount.amount) + try { + const quote = await BlockchainApiController.getOnrampQuote({ + purchaseCurrency: state.purchaseCurrency, + paymentCurrency: state.paymentCurrency, + amount: state.paymentAmount?.toString() || '0', + network: state.purchaseCurrency?.symbol + }) + state.quotesLoading = false + state.purchaseAmount = Number(quote.purchaseAmount.amount) + + return quote + } catch (error) { + state.error = (error as Error).message + state.quotesLoading = false + + return null + } finally { + state.quotesLoading = false + } + }, - return quote + resetState() { + state.providers = ONRAMP_PROVIDERS as OnRampProvider[] + state.selectedProvider = null + state.error = null + state.purchaseCurrency = USDC_CURRENCY_DEFAULT + state.paymentCurrency = USD_CURRENCY_DEFAULT + state.purchaseCurrencies = [USDC_CURRENCY_DEFAULT] + state.paymentCurrencies = [] + state.paymentAmount = undefined + state.purchaseAmount = undefined + state.quotesLoading = false } } diff --git a/packages/core/src/controllers/TransactionsController.ts b/packages/core/src/controllers/TransactionsController.ts index 9e296afdfd..095041948c 100644 --- a/packages/core/src/controllers/TransactionsController.ts +++ b/packages/core/src/controllers/TransactionsController.ts @@ -97,6 +97,7 @@ export const TransactionsController = { transactions.forEach(transaction => { const year = new Date(transaction.metadata.minedAt).getFullYear() const month = new Date(transaction.metadata.minedAt).getMonth() + const yearTransactions = grouped[year] ?? {} const monthTransactions = yearTransactions[month] ?? [] diff --git a/packages/core/tests/constants/OnrampTransactions.ts b/packages/core/tests/constants/OnrampTransactions.ts new file mode 100644 index 0000000000..0792bd4fe5 --- /dev/null +++ b/packages/core/tests/constants/OnrampTransactions.ts @@ -0,0 +1,151 @@ +export const ONRAMP_TRANSACTIONS_RESPONSES_JAN = { + SUCCESS: { + id: '1eeccf2f-ef04-6d48-a2dd-0e1dca1d3cfb', + metadata: { + operationType: 'buy', + hash: '0xbf5f116e0e77b304404ff873b527578d8c0a247732a50b0da174a533b669ab5b', + minedAt: '2024-01-15T16:59:37.345Z', + sentFrom: 'Coinbase', + sentTo: '0xf3ea39310011333095CFCcCc7c4Ad74034CABA64', + status: 'ONRAMP_TRANSACTION_STATUS_SUCCESS', + nonce: 1, + chain: 'eip155:137' + }, + transfers: [ + { + fungible_info: { + name: 'USDC', + symbol: 'USDC' + }, + direction: 'in', + quantity: { + numeric: '3.003898' + } + } + ] + }, + FAILED: { + id: '1eeccf2f-ef04-6d48-a2dd-0e1dca1d3cfb', + metadata: { + operationType: 'buy', + hash: '', + minedAt: '2024-01-15T16:59:37.345Z', + sentFrom: 'Coinbase', + sentTo: '0xf3ea39310011333095CFCcCc7c4Ad74034CABA64', + status: 'ONRAMP_TRANSACTION_STATUS_FAILED', + nonce: 1 + }, + transfers: [ + { + fungible_info: { + name: 'USDC', + symbol: 'USDC' + }, + direction: 'in', + quantity: { + numeric: '4.995375' + } + } + ] + }, + IN_PROGRESS: { + id: '1eeccf2f-ef04-6d48-a2dd-0e1dca1d3cfb', + metadata: { + operationType: 'buy', + hash: '', + minedAt: '2024-01-15T16:59:37.345Z', + sentFrom: 'Coinbase', + sentTo: '0xf3ea39310011333095CFCcCc7c4Ad74034CABA64', + status: 'ONRAMP_TRANSACTION_STATUS_IN_PROGRESS', + nonce: 1 + }, + transfers: [ + { + fungible_info: { + name: 'USDC', + symbol: 'USDC' + }, + direction: 'in', + quantity: { + numeric: '4.995375' + } + } + ] + } +} + +export const ONRAMP_TRANSACTIONS_RESPONSES_FEB = { + SUCCESS: { + id: '1eecc239-9ed5-696e-afeb-129d128962f1', + metadata: { + operationType: 'buy', + hash: '0xbf5f116e0e77b304404ff873b527578d8c0a247732a50b0da174a533b669ab5b', + minedAt: '2024-02-15T16:59:37.345Z', + sentFrom: 'Coinbase', + sentTo: '0xf3ea39310011333095CFCcCc7c4Ad74034CABA64', + status: 'ONRAMP_TRANSACTION_STATUS_SUCCESS', + nonce: 1, + chain: 'eip155:137' + }, + transfers: [ + { + fungible_info: { + name: 'USDC', + symbol: 'USDC' + }, + direction: 'in', + quantity: { + numeric: '3.003898' + } + } + ] + }, + FAILED: { + id: '1eecc239-9ed5-696e-afeb-129d128962f1', + metadata: { + operationType: 'buy', + hash: '', + minedAt: '2024-02-15T16:59:37.345Z', + sentFrom: 'Coinbase', + sentTo: '0xf3ea39310011333095CFCcCc7c4Ad74034CABA64', + status: 'ONRAMP_TRANSACTION_STATUS_FAILED', + nonce: 1 + }, + transfers: [ + { + fungible_info: { + name: 'USDC', + symbol: 'USDC' + }, + direction: 'in', + quantity: { + numeric: '4.995375' + } + } + ] + }, + IN_PROGRESS: { + id: '1eecc239-9ed5-696e-afeb-129d128962f1', + metadata: { + operationType: 'buy', + hash: '', + minedAt: '2024-02-15T16:59:37.345Z', + sentFrom: 'Coinbase', + sentTo: '0xf3ea39310011333095CFCcCc7c4Ad74034CABA64', + status: 'ONRAMP_TRANSACTION_STATUS_IN_PROGRESS', + nonce: 1 + }, + transfers: [ + { + fungible_info: { + name: 'USDC', + symbol: 'USDC' + }, + direction: 'in', + quantity: { + numeric: '4.995375' + } + } + ] + } +} diff --git a/packages/core/tests/controllers/OnRampController.test.ts b/packages/core/tests/controllers/OnRampController.test.ts new file mode 100644 index 0000000000..5bc6789b05 --- /dev/null +++ b/packages/core/tests/controllers/OnRampController.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest' +import { + ApiController, + BlockchainApiController, + OnRampController, + type OnRampProvider, + type PaymentCurrency, + type PurchaseCurrency +} from '../../index.js' +import { ONRAMP_PROVIDERS } from '../../src/utils/ConstantsUtil.js' +import { + USDC_CURRENCY_DEFAULT, + USD_CURRENCY_DEFAULT +} from '../../src/controllers/OnRampController.js' + +const purchaseCurrencies: [PurchaseCurrency, ...PurchaseCurrency[]] = [ + { id: 'test-coin', symbol: 'TEST', name: 'Test Coin', networks: [] }, + { id: 'test-coin-2', symbol: 'TES2', name: 'Test Coin 2', networks: [] } +] +const paymentCurrencies: [PaymentCurrency, ...PaymentCurrency[]] = [ + { id: 'test-currency', payment_method_limits: [] }, + { id: 'test-currency-2', payment_method_limits: [] } +] + +const mockQuote = { + paymentTotal: { + amount: '100', + currency: 'USD' + }, + paymentSubtotal: { + amount: '200', + currency: 'USD' + }, + purchaseAmount: { + amount: '100', + currency: 'USDC' + }, + coinbaseFee: { + amount: '50', + currency: 'USD' + }, + networkFee: { + amount: '50', + currency: 'USD' + }, + quoteId: 'test' +} + +const defaultState = { + providers: ONRAMP_PROVIDERS as OnRampProvider[], + selectedProvider: null, + error: null, + purchaseCurrency: USDC_CURRENCY_DEFAULT, + paymentCurrency: USD_CURRENCY_DEFAULT, + purchaseCurrencies: [USDC_CURRENCY_DEFAULT], + paymentCurrencies: [], + quotesLoading: false +} + +// -- Tests -------------------------------------------------------------------- +describe('OnRampController', () => { + it('should have valid default state', () => { + expect(OnRampController.state).toEqual(defaultState) + }) + + it('should get available currencies and properly update state', async () => { + OnRampController.resetState() + const getOnrampOptions = vi + .spyOn(BlockchainApiController, 'getOnrampOptions') + .mockResolvedValueOnce({ + purchaseCurrencies, + paymentCurrencies + }) + + const fetchCurrencyImages = vi + .spyOn(ApiController, 'fetchCurrencyImages') + .mockResolvedValueOnce(undefined) + + const fetchTokenImages = vi + .spyOn(ApiController, 'fetchTokenImages') + .mockResolvedValueOnce(undefined) + + await OnRampController.getAvailableCurrencies() + expect(getOnrampOptions).toHaveBeenCalled() + expect(fetchCurrencyImages).toHaveBeenCalledWith( + paymentCurrencies?.map(currency => currency.id) + ) + expect(fetchTokenImages).toHaveBeenCalledWith(purchaseCurrencies?.map(token => token.symbol)) + expect(OnRampController.state.purchaseCurrencies).toEqual(purchaseCurrencies) + expect(OnRampController.state.paymentCurrencies).toEqual(paymentCurrencies) + }) + + it('should get quotes and properly update state with default params', async () => { + OnRampController.resetState() + const getOnrampQuote = vi + .spyOn(BlockchainApiController, 'getOnrampQuote') + .mockResolvedValue(mockQuote) + + const quote = await OnRampController.getQuote() + expect(quote).toEqual(mockQuote) + expect(getOnrampQuote).toHaveBeenCalledWith({ + purchaseCurrency: USDC_CURRENCY_DEFAULT, + paymentCurrency: USD_CURRENCY_DEFAULT, + amount: '0', + network: 'USDC' + }) + }) + + it('should get quotes and properly update state with set state', async () => { + OnRampController.resetState() + + const getOnrampQuote = vi + .spyOn(BlockchainApiController, 'getOnrampQuote') + .mockResolvedValue(mockQuote) + + OnRampController.setPaymentAmount(100) + OnRampController.setPurchaseCurrency(purchaseCurrencies[0]) + OnRampController.setPaymentCurrency(paymentCurrencies[0]) + + const quote = await OnRampController.getQuote() + + expect(quote).toEqual(mockQuote) + expect(getOnrampQuote).toHaveBeenCalledWith({ + purchaseCurrency: purchaseCurrencies[0], + paymentCurrency: paymentCurrencies[0], + amount: '100', + network: 'TEST' + }) + + expect(OnRampController.state.purchaseAmount).toEqual(100) + expect(OnRampController.state.quotesLoading).toEqual(false) + }) + + it('should set error when failing to get quotes', async () => { + OnRampController.resetState() + const error = new Error('Test error') + const getOnrampQuote = vi + .spyOn(BlockchainApiController, 'getOnrampQuote') + .mockRejectedValue(error) + + const quote = await OnRampController.getQuote() + + expect(quote).toEqual(null) + expect(getOnrampQuote).toHaveBeenCalled() + expect(OnRampController.state.error).toEqual(error.message) + expect(OnRampController.state.quotesLoading).toEqual(false) + }) +}) diff --git a/packages/core/tests/controllers/OnRampController.ts b/packages/core/tests/controllers/OnRampController.ts deleted file mode 100644 index bf90772db3..0000000000 --- a/packages/core/tests/controllers/OnRampController.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { OnRampController } from '../../index.js' -import { ONRAMP_PROVIDERS } from '../../src/utils/ConstantsUtil.js' - -// -- Tests -------------------------------------------------------------------- -describe('OnRampController', () => { - it('should have valid default state', () => { - expect(OnRampController.state).toEqual({ - providers: ONRAMP_PROVIDERS, - selectedProvider: null, - error: null - }) - }) - - it('should update state correctly on setProjectId()', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - OnRampController.setSelectedProvider(ONRAMP_PROVIDERS[0] as any) - expect(OnRampController.state.selectedProvider).toEqual(ONRAMP_PROVIDERS[0]) - }) -}) diff --git a/packages/core/tests/controllers/TransactionsController.test.ts b/packages/core/tests/controllers/TransactionsController.test.ts index 465a50dfd2..75c2833b4b 100644 --- a/packages/core/tests/controllers/TransactionsController.test.ts +++ b/packages/core/tests/controllers/TransactionsController.test.ts @@ -1,16 +1,241 @@ -import { describe, expect, it } from 'vitest' -import { TransactionsController } from '../../index.js' +import { describe, expect, it, vi } from 'vitest' +import { BlockchainApiController, OptionsController, TransactionsController } from '../../index.js' +import { + ONRAMP_TRANSACTIONS_RESPONSES_FEB, + ONRAMP_TRANSACTIONS_RESPONSES_JAN +} from '../constants/OnrampTransactions.js' +import type { Transaction } from '@web3modal/common' + +// -- Constants ---------------------------------------------------------------- +const projectId = '123' +OptionsController.state.projectId = projectId +const defaultState = { + transactions: [], + transactionsByYear: {}, + loading: false, + empty: false, + next: undefined, + coinbaseTransactions: {} +} // -- Tests -------------------------------------------------------------------- describe('TransactionsController', () => { it('should have valid default state', () => { - expect(TransactionsController.state).toEqual({ - transactions: [], - transactionsByYear: {}, - loading: false, - empty: false, - next: undefined, - coinbaseTransactions: {} + expect(TransactionsController.state).toEqual(defaultState) + }) + + it('should fetch onramp transactions and group them appropiately', async () => { + const accountAddress = ONRAMP_TRANSACTIONS_RESPONSES_JAN.SUCCESS.metadata.sentTo + + const response = { + data: [ + ONRAMP_TRANSACTIONS_RESPONSES_JAN.SUCCESS, + ONRAMP_TRANSACTIONS_RESPONSES_FEB.FAILED + ] as Transaction[], + next: '' + } + + const fetchTransactions = vi + .spyOn(BlockchainApiController, 'fetchTransactions') + .mockResolvedValue(response) + + await TransactionsController.fetchTransactions(accountAddress, 'coinbase') + + expect(fetchTransactions).toHaveBeenCalledWith({ + account: accountAddress, + projectId, + onramp: 'coinbase', + cursor: undefined + }) + + expect(TransactionsController.state.transactions).toEqual([]) + expect(TransactionsController.state.transactionsByYear).toEqual({}) + expect(TransactionsController.state.coinbaseTransactions).toEqual({ + 2024: { + 0: [ONRAMP_TRANSACTIONS_RESPONSES_JAN.SUCCESS], + 1: [ONRAMP_TRANSACTIONS_RESPONSES_FEB.FAILED] + } + }) + }) + + it('should update onramp transaction from pending to success', async () => { + const { SUCCESS, IN_PROGRESS } = ONRAMP_TRANSACTIONS_RESPONSES_FEB + const accountAddress = SUCCESS.metadata.sentTo + + // Manually clear state - vitest hooks are wiping state prematurely + TransactionsController.state.coinbaseTransactions = {} + + const pendingResponse = { + data: [IN_PROGRESS] as Transaction[], + next: '' + } + + const fetchTransactions = vi + .spyOn(BlockchainApiController, 'fetchTransactions') + .mockResolvedValue(pendingResponse) + + await TransactionsController.fetchTransactions(accountAddress, 'coinbase') + + expect(fetchTransactions).toHaveBeenCalledWith({ + account: accountAddress, + projectId, + onramp: 'coinbase', + cursor: undefined + }) + + expect(TransactionsController.state.transactions).toEqual([]) + expect(TransactionsController.state.transactionsByYear).toEqual({}) + expect(TransactionsController.state.coinbaseTransactions).toEqual({ + 2024: { + 1: [IN_PROGRESS] + } + }) + + // Update the transaction + const successResponse = { + data: [SUCCESS] as Transaction[], + next: '' + } + + fetchTransactions.mockResolvedValue(successResponse) + + await TransactionsController.fetchTransactions(accountAddress, 'coinbase') + + expect(fetchTransactions).toHaveBeenCalledWith({ + account: accountAddress, + projectId, + onramp: 'coinbase', + cursor: undefined + }) + + // Transaction should be replaced + expect(TransactionsController.state.transactions).toEqual([]) + expect(TransactionsController.state.transactionsByYear).toEqual({}) + expect(TransactionsController.state.coinbaseTransactions).toEqual({ + 2024: { + 1: [SUCCESS] + } + }) + }) + + it('should update onramp transaction from pending to failed', async () => { + const { FAILED, IN_PROGRESS } = ONRAMP_TRANSACTIONS_RESPONSES_FEB + const accountAddress = FAILED.metadata.sentTo + + // Manually clear state - vitest hooks are wiping state prematurely + TransactionsController.state.coinbaseTransactions = {} + + const pendingResponse = { + data: [IN_PROGRESS] as Transaction[], + next: '' + } + + const fetchTransactions = vi + .spyOn(BlockchainApiController, 'fetchTransactions') + .mockResolvedValue(pendingResponse) + + await TransactionsController.fetchTransactions(accountAddress, 'coinbase') + + expect(fetchTransactions).toHaveBeenCalledWith({ + account: accountAddress, + projectId, + onramp: 'coinbase', + cursor: undefined + }) + + expect(TransactionsController.state.transactions).toEqual([]) + expect(TransactionsController.state.transactionsByYear).toEqual({}) + expect(TransactionsController.state.coinbaseTransactions).toEqual({ + 2024: { + 1: [IN_PROGRESS] + } + }) + + // Update the transaction + const successResponse = { + data: [FAILED] as Transaction[], + next: '' + } + + fetchTransactions.mockResolvedValue(successResponse) + + await TransactionsController.fetchTransactions(accountAddress, 'coinbase') + + expect(fetchTransactions).toHaveBeenCalledWith({ + account: accountAddress, + projectId, + onramp: 'coinbase', + cursor: undefined + }) + + // Transaction should be replaced + expect(TransactionsController.state.transactions).toEqual([]) + expect(TransactionsController.state.transactionsByYear).toEqual({}) + expect(TransactionsController.state.coinbaseTransactions).toEqual({ + 2024: { + 1: [FAILED] + } + }) + }) + + it('should push new onramp transactions while updating old ones', async () => { + const { SUCCESS, IN_PROGRESS } = ONRAMP_TRANSACTIONS_RESPONSES_JAN + const accountAddress = SUCCESS.metadata.sentTo + + // Manually clear state - vitest hooks are wiping state prematurely + TransactionsController.state.coinbaseTransactions = {} + + const pendingResponse = { + data: [IN_PROGRESS] as Transaction[], + next: '' + } + + const fetchTransactions = vi + .spyOn(BlockchainApiController, 'fetchTransactions') + .mockResolvedValue(pendingResponse) + + await TransactionsController.fetchTransactions(accountAddress, 'coinbase') + + expect(fetchTransactions).toHaveBeenCalledWith({ + account: accountAddress, + projectId, + onramp: 'coinbase', + cursor: undefined + }) + + expect(TransactionsController.state.transactions).toEqual([]) + expect(TransactionsController.state.transactionsByYear).toEqual({}) + expect(TransactionsController.state.coinbaseTransactions).toEqual({ + 2024: { + 0: [IN_PROGRESS] + } + }) + + // Update the transaction + const successResponse = { + data: [SUCCESS, ONRAMP_TRANSACTIONS_RESPONSES_FEB.IN_PROGRESS] as Transaction[], + next: '' + } + + fetchTransactions.mockResolvedValue(successResponse) + + await TransactionsController.fetchTransactions(accountAddress, 'coinbase') + + expect(fetchTransactions).toHaveBeenCalledWith({ + account: accountAddress, + projectId, + onramp: 'coinbase', + cursor: undefined + }) + + // Transaction should be replaced + expect(TransactionsController.state.transactions).toEqual([]) + expect(TransactionsController.state.transactionsByYear).toEqual({}) + expect(TransactionsController.state.coinbaseTransactions).toEqual({ + 2024: { + 0: [SUCCESS], + 1: [ONRAMP_TRANSACTIONS_RESPONSES_FEB.IN_PROGRESS] + } }) }) })