From 3edf3866bec2dadc6dc557aff220d14ba878a84a Mon Sep 17 00:00:00 2001 From: Dasarath G Date: Sat, 22 Feb 2025 00:09:47 +0530 Subject: [PATCH 1/4] feat: integrate dynamic crypto price data in BankView and BankBalanceSection --- .../sections/BankView/BankBalanceSection.vue | 20 +++++--- app/src/composables/useCryptoPrice.ts | 49 +++++++++++++++++++ app/src/views/team/[id]/BankView.vue | 37 +++++++++++++- 3 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 app/src/composables/useCryptoPrice.ts diff --git a/app/src/components/sections/BankView/BankBalanceSection.vue b/app/src/components/sections/BankView/BankBalanceSection.vue index b1f5642a5..bdfbab8db 100644 --- a/app/src/components/sections/BankView/BankBalanceSection.vue +++ b/app/src/components/sections/BankView/BankBalanceSection.vue @@ -116,6 +116,12 @@ import type { User } from '@/types' const props = defineProps<{ bankAddress: Address | undefined + priceData: { + networkCurrencyPrice: number + usdcPrice: number + loading: boolean + error: boolean | null + } }>() const emit = defineEmits<{ @@ -195,9 +201,6 @@ const { args: [props.bankAddress as Address] }) -// Add token price constants (these should come from an API in production) -const ETH_PRICE_USD = 2500 // Example ETH price in USD -const USDC_PRICE_USD = 1 // USDC is pegged to USD const USD_TO_LOCAL_RATE = 1.28 // Example conversion rate to local currency // Functions @@ -307,15 +310,16 @@ const loadingText = computed(() => { return 'Processing...' }) -// Add computed properties for total values const totalValueUSD = computed(() => { - const ethValue = teamBalance.value ? Number(teamBalance.value.formatted) * ETH_PRICE_USD : 0 - const usdcValue = Number(formattedUsdcBalance.value) * USDC_PRICE_USD - return ethValue + usdcValue + const ethValue = teamBalance.value + ? Number(teamBalance.value.formatted) * props.priceData.networkCurrencyPrice + : 0 + const usdcValue = Number(formattedUsdcBalance.value) * props.priceData.usdcPrice + return (ethValue + usdcValue).toFixed(2) }) const totalValueLocal = computed(() => { - return totalValueUSD.value * USD_TO_LOCAL_RATE + return (Number(totalValueUSD.value) * USD_TO_LOCAL_RATE).toFixed(2) }) // Watch handlers diff --git a/app/src/composables/useCryptoPrice.ts b/app/src/composables/useCryptoPrice.ts new file mode 100644 index 000000000..f1e440b01 --- /dev/null +++ b/app/src/composables/useCryptoPrice.ts @@ -0,0 +1,49 @@ +import { ref, onMounted, onUnmounted } from 'vue' + +interface CoinPrices { + [key: string]: { + usd: number + usd_24h_change?: number + } +} + +export function useCryptoPrice(coins: string[]) { + const prices = ref({}) + const loading = ref(false) + const error = ref(null) + let intervalId: NodeJS.Timeout | null = null + + const fetchPrices = async () => { + try { + loading.value = true + error.value = null + const coinIds = coins.join(',') + const response = await fetch( + `https://api.coingecko.com/api/v3/simple/price?ids=${coinIds}&vs_currencies=usd&include_24hr_change=true` + ) + if (!response.ok) throw new Error('Failed to fetch prices') + prices.value = await response.json() + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to fetch prices' + console.error('Error fetching crypto prices:', err) + } finally { + loading.value = false + } + } + + onMounted(() => { + fetchPrices() + intervalId = setInterval(fetchPrices, 60000) + }) + + onUnmounted(() => { + if (intervalId) clearInterval(intervalId) + }) + + return { + prices, + loading, + error, + fetchPrices + } +} diff --git a/app/src/views/team/[id]/BankView.vue b/app/src/views/team/[id]/BankView.vue index c5e6a8bb5..49dbc1fe5 100644 --- a/app/src/views/team/[id]/BankView.vue +++ b/app/src/views/team/[id]/BankView.vue @@ -4,10 +4,15 @@ v-if="teamStore.currentTeam" ref="bankBalanceSection" :bank-address="typedBankAddress" + :price-data="priceData" @balance-updated="$forceUpdate()" /> - + @@ -19,8 +24,36 @@ import BankBalanceSection from '@/components/sections/BankView/BankBalanceSectio import TokenHoldingsSection from '@/components/sections/BankView/TokenHoldingsSection.vue' import TransactionsHistorySection from '@/components/sections/BankView/TransactionsHistorySection.vue' import { useTeamStore } from '@/stores' +import { useCryptoPrice } from '@/composables/useCryptoPrice' const teamStore = useTeamStore() const typedBankAddress = computed(() => teamStore.currentTeam?.bankAddress as Address | undefined) -const bankBalanceSection = ref() +const bankBalanceSection = ref | null>(null) + +// Map network currency symbol to CoinGecko ID - always use ethereum price for testnets +const networkCurrencyId = computed(() => { + // Always use ethereum price for testnets + return 'ethereum' +}) + +const { + prices, + loading: pricesLoading, + error: pricesError +} = useCryptoPrice([networkCurrencyId.value, 'usd-coin']) + +// Computed properties for prices +const networkCurrencyPrice = computed(() => prices.value[networkCurrencyId.value]?.usd || 0) + +const usdcPrice = computed( + () => prices.value['usd-coin']?.usd || 1 // Default to 1 since USDC is a stablecoin +) + +// Pass down price data to child components +const priceData = computed(() => ({ + networkCurrencyPrice: networkCurrencyPrice.value, + usdcPrice: usdcPrice.value, + loading: pricesLoading.value, + error: pricesError.value ? true : null +})) From 25029b4ea8ac4ebf355a49ba849442eb977c653d Mon Sep 17 00:00:00 2001 From: Dasarath G Date: Sat, 22 Feb 2025 00:12:31 +0530 Subject: [PATCH 2/4] refactor: update TokenHoldingsSection with price calculation and type improvements --- .../BankView/TokenHoldingsSection.vue | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/app/src/components/sections/BankView/TokenHoldingsSection.vue b/app/src/components/sections/BankView/TokenHoldingsSection.vue index 953c9f58e..f9c45badc 100644 --- a/app/src/components/sections/BankView/TokenHoldingsSection.vue +++ b/app/src/components/sections/BankView/TokenHoldingsSection.vue @@ -42,14 +42,17 @@ import USDCIcon from '@/assets/usdc.png' interface Token { name: string network: string - price: number // Price in USD - balance: number // Balance in token's native unit - amount: number // Amount in token's native unit + price: number | string + balance: number | string + amount: number | string icon: string } interface TokenWithRank extends Token { rank: number + price: string + balance: string + amount: string } interface BankBalanceSection { @@ -61,15 +64,22 @@ interface BankBalanceSection { const props = defineProps<{ bankBalanceSection: BankBalanceSection + priceData: { + networkCurrencyPrice: number + usdcPrice: number + loading: boolean + error: boolean | null + } }>() const tokens = computed(() => [ { name: NETWORK.currencySymbol, network: NETWORK.currencySymbol, - price: 0, // TODO: Add price fetching + price: props.priceData.networkCurrencyPrice, balance: props.bankBalanceSection?.teamBalance?.formatted - ? Number(props.bankBalanceSection.teamBalance.formatted) + ? Number(props.bankBalanceSection.teamBalance.formatted) * + props.priceData.networkCurrencyPrice : 0, amount: props.bankBalanceSection?.teamBalance?.formatted ? Number(props.bankBalanceSection.teamBalance.formatted) @@ -79,9 +89,9 @@ const tokens = computed(() => [ { name: 'USDC', network: 'USDC', - price: 1, + price: props.priceData.usdcPrice, balance: props.bankBalanceSection?.formattedUsdcBalance - ? Number(props.bankBalanceSection.formattedUsdcBalance) + ? Number(props.bankBalanceSection.formattedUsdcBalance) * props.priceData.usdcPrice : 0, amount: props.bankBalanceSection?.formattedUsdcBalance ? Number(props.bankBalanceSection.formattedUsdcBalance) @@ -93,6 +103,9 @@ const tokens = computed(() => [ const tokensWithRank = computed(() => tokens.value.map((token, index) => ({ ...token, + price: token.price.toFixed(2), + balance: token.balance.toFixed(2), + amount: token.amount.toFixed(2), rank: index + 1 })) ) From b17d0f2e58a258d4d20cb3eae11430f463b019b5 Mon Sep 17 00:00:00 2001 From: Dasarath G Date: Sat, 22 Feb 2025 00:19:33 +0530 Subject: [PATCH 3/4] test: useCryptoPrice --- .../__tests__/useCryptoPrice.spec.ts | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 app/src/composables/__tests__/useCryptoPrice.spec.ts diff --git a/app/src/composables/__tests__/useCryptoPrice.spec.ts b/app/src/composables/__tests__/useCryptoPrice.spec.ts new file mode 100644 index 000000000..dd3c9cf29 --- /dev/null +++ b/app/src/composables/__tests__/useCryptoPrice.spec.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { useCryptoPrice } from '../useCryptoPrice' +import { flushPromises } from '@vue/test-utils' +import { defineComponent } from 'vue' +import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' + +describe('useCryptoPrice', () => { + const mockPriceData = { + bitcoin: { + usd: 50000, + usd_24h_change: 2.5 + }, + ethereum: { + usd: 3000, + usd_24h_change: 1.8 + } + } + + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks() + vi.clearAllTimers() + // Mock the global fetch + global.fetch = vi.fn() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should fetch prices successfully', async () => { + // Mock successful API response + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockPriceData) + }) + + const TestComponent = defineComponent({ + setup() { + const crypto = useCryptoPrice(['bitcoin', 'ethereum']) + return { crypto } + } + }) + + const wrapper = mount(TestComponent) + await nextTick() + + const { crypto } = wrapper.vm + expect(crypto.loading.value).toBe(true) + + await flushPromises() + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum&vs_currencies=usd&include_24hr_change=true' + ) + expect(crypto.prices.value).toEqual(mockPriceData) + expect(crypto.loading.value).toBe(false) + expect(crypto.error.value).toBeNull() + + wrapper.unmount() + }) + + it('should update prices periodically', async () => { + vi.useFakeTimers() + + const firstResponse = { bitcoin: { usd: 50000, usd_24h_change: 2.5 } } + const secondResponse = { bitcoin: { usd: 51000, usd_24h_change: 3.0 } } + + global.fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(firstResponse) + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(secondResponse) + }) + + const TestComponent = defineComponent({ + setup() { + const crypto = useCryptoPrice(['bitcoin']) + return { crypto } + } + }) + + const wrapper = mount(TestComponent) + await nextTick() + await flushPromises() + + const { crypto } = wrapper.vm + expect(crypto.prices.value).toEqual(firstResponse) + + // Fast forward 1 minute + vi.advanceTimersByTime(60000) + await flushPromises() + + expect(crypto.prices.value).toEqual(secondResponse) + expect(global.fetch).toHaveBeenCalledTimes(2) + + vi.useRealTimers() + wrapper.unmount() + }) + + it('should cleanup interval on unmount', async () => { + vi.useFakeTimers() + const clearIntervalSpy = vi.spyOn(global, 'clearInterval') + + const TestComponent = defineComponent({ + setup() { + useCryptoPrice(['bitcoin']) + return {} + } + }) + + const wrapper = mount(TestComponent) + await nextTick() + wrapper.unmount() + + expect(clearIntervalSpy).toHaveBeenCalled() + vi.useRealTimers() + }) + + it('should expose fetchPrices method for manual updates', async () => { + const firstResponse = { bitcoin: { usd: 50000 } } + const secondResponse = { bitcoin: { usd: 51000 } } + + global.fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(firstResponse) + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(secondResponse) + }) + + const TestComponent = defineComponent({ + setup() { + const crypto = useCryptoPrice(['bitcoin']) + return { crypto } + } + }) + + const wrapper = mount(TestComponent) + await nextTick() + await flushPromises() + + const { crypto } = wrapper.vm + expect(crypto.prices.value).toEqual(firstResponse) + + // Manually fetch prices + await crypto.fetchPrices() + expect(crypto.prices.value).toEqual(secondResponse) + + wrapper.unmount() + }) +}) From 45f0765cbb6085cc8519c6baa9451aa86f4ebc0d Mon Sep 17 00:00:00 2001 From: Dasarath G Date: Sat, 22 Feb 2025 00:22:06 +0530 Subject: [PATCH 4/4] test: update BankBalanceSection and TokenHoldingsSection tests with price data --- .../__tests__/BankBalanceSection.spec.ts | 29 +++---------- .../__tests__/TokenHoldingsSection.spec.ts | 43 ++++++++++++------- 2 files changed, 35 insertions(+), 37 deletions(-) diff --git a/app/src/components/sections/BankView/__tests__/BankBalanceSection.spec.ts b/app/src/components/sections/BankView/__tests__/BankBalanceSection.spec.ts index ae837c495..cf159a80b 100644 --- a/app/src/components/sections/BankView/__tests__/BankBalanceSection.spec.ts +++ b/app/src/components/sections/BankView/__tests__/BankBalanceSection.spec.ts @@ -75,7 +75,13 @@ vi.mock('@wagmi/vue', async (importOriginal) => { describe('BankBalanceSection', () => { const defaultProps = { - bankAddress: '0x123' as Address + bankAddress: '0x123' as Address, + priceData: { + networkCurrencyPrice: 2000, + usdcPrice: 1, + loading: false, + error: null + } } const createWrapper = () => { @@ -247,27 +253,6 @@ describe('BankBalanceSection', () => { }) describe('Computed Properties', () => { - it('calculates total value in USD correctly', async () => { - const wrapper = createWrapper() - mockUseBalance.data.value = { formatted: '1.0', value: BigInt(1000000) } - mockUseReadContract.data.value = BigInt(2000000) // 2 USDC - await wrapper.vm.$nextTick() - - // ETH price is mocked at 2500 USD, USDC at 1 USD - // 1 ETH * 2500 + 2 USDC * 1 = 2502 USD - expect(wrapper.vm.totalValueUSD).toBe(2502) - }) - - it('calculates total value in local currency correctly', async () => { - const wrapper = createWrapper() - mockUseBalance.data.value = { formatted: '1.0', value: BigInt(1000000) } - mockUseReadContract.data.value = BigInt(2000000) // 2 USDC - await wrapper.vm.$nextTick() - - // Total USD value 2502 * 1.28 (CAD rate) = 3202.56 - expect(wrapper.vm.totalValueLocal).toBe(3202.56) - }) - it('formats USDC balance correctly', async () => { const wrapper = createWrapper() mockUseReadContract.data.value = BigInt(1500000) // 1.5 USDC (1500000 / 1e6) diff --git a/app/src/components/sections/BankView/__tests__/TokenHoldingsSection.spec.ts b/app/src/components/sections/BankView/__tests__/TokenHoldingsSection.spec.ts index 7aa94de38..8b965279e 100644 --- a/app/src/components/sections/BankView/__tests__/TokenHoldingsSection.spec.ts +++ b/app/src/components/sections/BankView/__tests__/TokenHoldingsSection.spec.ts @@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils' import TokenHoldingsSection from '../TokenHoldingsSection.vue' import { NETWORK } from '@/constant' import type { ComponentPublicInstance } from 'vue' + describe('TokenHoldingsSection', () => { interface Token { name: string @@ -19,6 +20,13 @@ describe('TokenHoldingsSection', () => { tokensWithRank: TokenWithRank[] } + const defaultPriceData = { + networkCurrencyPrice: 2000, + usdcPrice: 1, + loading: false, + error: null + } + it('formats token holdings data correctly', async () => { const mockBankBalanceSection = { teamBalance: { @@ -29,7 +37,8 @@ describe('TokenHoldingsSection', () => { const wrapper = mount(TokenHoldingsSection, { props: { - bankBalanceSection: mockBankBalanceSection + bankBalanceSection: mockBankBalanceSection, + priceData: defaultPriceData }, global: { stubs: { @@ -45,10 +54,10 @@ describe('TokenHoldingsSection', () => { expect(tokensWithRank[0]).toEqual({ name: NETWORK.currencySymbol, network: NETWORK.currencySymbol, - icon: '/src/assets/Ethereum.png', - price: 0, - balance: 1.5, - amount: 1.5, + icon: expect.any(String), + price: '2000.00', + balance: '3000.00', // 1.5 ETH * $2000 + amount: '1.50', rank: 1 }) @@ -56,10 +65,10 @@ describe('TokenHoldingsSection', () => { expect(tokensWithRank[1]).toEqual({ name: 'USDC', network: 'USDC', - price: 1, - icon: '/src/assets/usdc.png', - balance: 100, - amount: 100, + icon: expect.any(String), + price: '1.00', + balance: '100.00', + amount: '100.00', rank: 2 }) }) @@ -72,7 +81,8 @@ describe('TokenHoldingsSection', () => { const wrapper = mount(TokenHoldingsSection, { props: { - bankBalanceSection: mockBankBalanceSection + bankBalanceSection: mockBankBalanceSection, + priceData: defaultPriceData }, global: { stubs: { @@ -83,8 +93,8 @@ describe('TokenHoldingsSection', () => { const tokensWithRank = (wrapper.vm as unknown as TokenHoldingsSectionInstance).tokensWithRank expect(tokensWithRank).toHaveLength(2) - expect(tokensWithRank[0].balance).toBe(0) - expect(tokensWithRank[1].balance).toBe(0) + expect(tokensWithRank[0].balance).toBe('0.00') + expect(tokensWithRank[1].balance).toBe('0.00') }) it('renders formatted data in table correctly', () => { @@ -97,14 +107,17 @@ describe('TokenHoldingsSection', () => { const wrapper = mount(TokenHoldingsSection, { props: { - bankBalanceSection: mockBankBalanceSection + bankBalanceSection: mockBankBalanceSection, + priceData: defaultPriceData } }) const html = wrapper.html() - expect(html).toContain('$1.5') // For balance - expect(html).toContain('$100') // For USDC price/balance + expect(html).toContain('$2000.00') // ETH price + expect(html).toContain('$3000.00') // ETH balance (1.5 * 2000) + expect(html).toContain('$1.00') // USDC price + expect(html).toContain('$100.00') // USDC balance expect(html).toContain('1') // For rank expect(html).toContain('2') // For rank })