Skip to content

Commit ee5434b

Browse files
ganchoradkovclaude
andauthored
feat(wallet): add per-chain balance display (#968)
* feat(wallet): integrate WalletConnect Pay SDK Add support for WalletConnect Pay payment links in the sample wallet: - Add PayStore for Pay SDK client management - Add PaymentOptionsModal with full payment flow: - Loading, collect data, options, confirming, success/error states - Form validation for required fields and date formats - Typed data signing with EIP712 - Add payment link detection utilities (isPaymentLink, extractPaymentLink) - Add API proxy route to bypass CORS restrictions - Register PaymentOptionsModal in modal system - Initialize Pay SDK during app startup Payment links can be pasted or scanned via QR code. Supports both direct URLs (pay.walletconnect.com/?pid=xxx) and WC URIs with pay parameter. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(wallet): use correct PaymentInfo.amount property The PaymentInfo type has `amount` not `totalAmount`. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(wallet): add intro screen to Pay modal Add a welcome/intro screen shown when opening the payment modal, featuring merchant info, payment amount, and a two-step timeline showing the payment flow before proceeding. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(wallet): redesign Pay modal form and payment info screens - Add multi-step form wizard with progress dots - Group fields into steps (name, date of birth, etc.) - Add payment info screen with merchant logo, amount display - Add payment method dropdown selector with dark theme - Improve navigation between form steps and screens Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(wallet): split PaymentOptionsModal into smaller components Extract PaymentOptionsModal (~1130 lines) into focused components: - styles.ts: All styled components - utils.ts: Utility functions and types - LoadingState, ErrorState, SuccessState, ConfirmingState: Simple state UIs - IntroScreen: Welcome/intro screen - CollectDataForm: Multi-step data collection form - PaymentInfoScreen: Payment confirmation screen Main component now handles orchestration only (~250 lines). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore(wallet): update Pay SDK to 1.0.2-canary.0 - Update @walletconnect/pay to 1.0.2-canary.0 - Update @reown/walletkit to 1.5.0 - Update WalletConnect packages to 2.23.4 - Use appId instead of projectId in Pay SDK initialization * feat(wallet): add balance display to Pay modal - Display user's token balance for selected payment option - Show network/chain info with icons in payment selector - Add ETA badge for estimated transaction time - Support multiple chains: Base, Optimism, Arbitrum, Ethereum - Parse CAIP-19 asset format to fetch ERC20 balances - Add styled components for balance card and payment options * feat(wallet): add per-chain balance display with multi-chain support Add balance button to each chain's AccountCard that displays native token and stablecoin (USDC/USDT) balances when clicked. - Add BalanceOverviewCard component for displaying balances - Add BalanceUtil with fetchers for all supported chains: - EIP155 (Ethereum, Polygon, etc.) via viem - Bitcoin via Blockstream API - Solana, SUI, Polkadot, Cosmos, Stacks, MultiversX, TON, Tezos, NEAR - Add RPC endpoints to all chain data files - Add native token symbols to chain configs - Include 10-second client-side cache for balance fetches - Support USDC/USDT token balances on EVM chains * fix(wallet): improve balance display with LRU cache and error handling - Add LRU cache with max size (100 entries) to prevent memory leaks - Add error state UI with retry button when balance fetch fails - Add loading indicator on balance button during fetch - Fix NEAR balance precision using formatUnits with 24 decimals - Fix Polkadot hex/decimal parsing for balance values - Extract namespace helper functions to reduce code duplication - Show chain logo for native token balance row Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e571eae commit ee5434b

File tree

17 files changed

+891
-99
lines changed

17 files changed

+891
-99
lines changed
Lines changed: 81 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import ChainCard from '@/components/ChainCard'
2+
import BalanceOverviewCard from '@/components/BalanceOverviewCard'
23
import SettingsStore from '@/store/SettingsStore'
3-
import { eip155Addresses } from '@/utils/EIP155WalletUtil'
44
import { truncate } from '@/utils/HelperUtil'
55
import { updateSignClientChainId } from '@/utils/WalletConnectUtil'
6-
import { Avatar, Button, Text, Tooltip } from '@nextui-org/react'
6+
import { Avatar, Button, Text, Tooltip, Loading } from '@nextui-org/react'
77
import Image from 'next/image'
8-
import { useState } from 'react'
8+
import { useState, useCallback } from 'react'
99
import { useSnapshot } from 'valtio'
1010

1111
interface Props {
@@ -18,7 +18,10 @@ interface Props {
1818

1919
export default function AccountCard({ name, logo, rgb, address = '', chainId }: Props) {
2020
const [copied, setCopied] = useState(false)
21-
const { activeChainId, account } = useSnapshot(SettingsStore.state)
21+
const [showBalance, setShowBalance] = useState(false)
22+
const [balanceLoading, setBalanceLoading] = useState(false)
23+
const { activeChainId } = useSnapshot(SettingsStore.state)
24+
2225
function onCopy() {
2326
navigator?.clipboard?.writeText(address)
2427
setCopied(true)
@@ -30,51 +33,82 @@ export default function AccountCard({ name, logo, rgb, address = '', chainId }:
3033
await updateSignClientChainId(chainId.toString(), address)
3134
}
3235

36+
const handleBalanceClick = (e: React.MouseEvent) => {
37+
e.stopPropagation()
38+
setShowBalance(!showBalance)
39+
}
40+
41+
const handleBalanceLoadingChange = useCallback((loading: boolean) => {
42+
setBalanceLoading(loading)
43+
}, [])
44+
3345
return (
34-
<ChainCard rgb={rgb} flexDirection="row" alignItems="center">
35-
<Avatar src={logo} />
36-
<div style={{ flex: 1 }}>
37-
<Text h5 css={{ marginLeft: '$9' }}>
38-
{name}
39-
</Text>
40-
<Text weight="light" size={13} css={{ marginLeft: '$9' }}>
41-
{address ? truncate(address, 19) : '<no address available>'}
42-
</Text>
43-
</div>
46+
<ChainCard rgb={rgb} flexDirection="column" alignItems="stretch">
47+
<div style={{ display: 'flex', alignItems: 'center' }}>
48+
<Avatar src={logo} />
49+
<div style={{ flex: 1 }}>
50+
<Text h5 css={{ marginLeft: '$9' }}>
51+
{name}
52+
</Text>
53+
<Text weight="light" size={13} css={{ marginLeft: '$9' }}>
54+
{address ? truncate(address, 19) : '<no address available>'}
55+
</Text>
56+
</div>
4457

45-
<Tooltip content={copied ? 'Copied!' : 'Copy'} placement="left">
46-
<Button
47-
size="sm"
48-
css={{ minWidth: 'auto', backgroundColor: 'rgba(255, 255, 255, 0.15)' }}
49-
data-testid={'chain-copy-button' + chainId}
50-
onClick={e => {
51-
e.stopPropagation()
52-
onCopy()
53-
}}
54-
>
55-
<Image
56-
src={copied ? '/icons/checkmark-icon.svg' : '/icons/copy-icon.svg'}
57-
width={15}
58-
height={15}
59-
alt="copy icon"
60-
/>
61-
</Button>
62-
</Tooltip>
63-
<Button
64-
size="sm"
65-
css={{
66-
minWidth: 'auto',
67-
backgroundColor: 'rgba(255, 255, 255, 0.15)',
68-
marginLeft: '$5'
69-
}}
70-
data-testid={'chain-switch-button' + chainId}
71-
onClick={e => {
72-
e.stopPropagation()
73-
onChainChanged(chainId, address)
74-
}}
75-
>
76-
{activeChainId === chainId ? `✅` : `🔄`}
77-
</Button>
58+
<div style={{ display: 'flex', gap: '8px' }}>
59+
<Tooltip content={showBalance ? 'Hide balance' : 'Show balance'} placement="left">
60+
<Button
61+
size="sm"
62+
css={{
63+
minWidth: 'auto',
64+
backgroundColor: showBalance ? 'rgba(23, 201, 100, 0.3)' : 'rgba(255, 255, 255, 0.15)'
65+
}}
66+
data-testid={'chain-balance-button' + chainId}
67+
onClick={handleBalanceClick}
68+
>
69+
{balanceLoading ? <Loading size="xs" color="white" /> : '💰'}
70+
</Button>
71+
</Tooltip>
72+
<Tooltip content={copied ? 'Copied!' : 'Copy'} placement="left">
73+
<Button
74+
size="sm"
75+
css={{ minWidth: 'auto', backgroundColor: 'rgba(255, 255, 255, 0.15)' }}
76+
data-testid={'chain-copy-button' + chainId}
77+
onClick={e => {
78+
e.stopPropagation()
79+
onCopy()
80+
}}
81+
>
82+
<Image
83+
src={copied ? '/icons/checkmark-icon.svg' : '/icons/copy-icon.svg'}
84+
width={15}
85+
height={15}
86+
alt="copy icon"
87+
/>
88+
</Button>
89+
</Tooltip>
90+
<Tooltip content={activeChainId === chainId ? 'Active chain' : 'Switch chain'} placement="left">
91+
<Button
92+
size="sm"
93+
css={{ minWidth: 'auto', backgroundColor: 'rgba(255, 255, 255, 0.15)' }}
94+
data-testid={'chain-switch-button' + chainId}
95+
onClick={e => {
96+
e.stopPropagation()
97+
onChainChanged(chainId, address)
98+
}}
99+
>
100+
{activeChainId === chainId ? `✅` : `🔄`}
101+
</Button>
102+
</Tooltip>
103+
</div>
104+
</div>
105+
{showBalance && (
106+
<BalanceOverviewCard
107+
chainId={chainId}
108+
address={address}
109+
onLoadingChange={handleBalanceLoadingChange}
110+
/>
111+
)}
78112
</ChainCard>
79113
)
80114
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { Loading, Text, Avatar, Button } from '@nextui-org/react'
2+
import { useEffect, useState, useCallback, useRef } from 'react'
3+
import { fetchAllBalances, AllBalancesResult, BalanceResult } from '@/utils/BalanceUtil'
4+
import { getChainData } from '@/data/chainsUtil'
5+
6+
interface Props {
7+
chainId: string
8+
address: string
9+
onLoadingChange?: (loading: boolean) => void
10+
}
11+
12+
const styles = {
13+
container: {
14+
marginTop: '8px',
15+
marginLeft: '36px',
16+
padding: '12px',
17+
borderRadius: '8px',
18+
backgroundColor: 'rgba(255, 255, 255, 0.05)'
19+
},
20+
row: {
21+
display: 'flex',
22+
justifyContent: 'space-between',
23+
alignItems: 'center',
24+
padding: '6px 0'
25+
},
26+
tokenInfo: {
27+
display: 'flex',
28+
alignItems: 'center',
29+
gap: '8px'
30+
},
31+
loadingContainer: {
32+
display: 'flex',
33+
alignItems: 'center',
34+
gap: '8px'
35+
},
36+
errorContainer: {
37+
display: 'flex',
38+
flexDirection: 'column' as const,
39+
alignItems: 'center',
40+
gap: '8px'
41+
}
42+
}
43+
44+
function BalanceRow({ balance, isNative }: { balance: BalanceResult; isNative?: boolean }) {
45+
return (
46+
<div style={styles.row}>
47+
<div style={styles.tokenInfo}>
48+
{balance.icon && (
49+
<Avatar src={balance.icon} size="xs" css={{ width: '16px', height: '16px' }} />
50+
)}
51+
<Text size={12} css={{ color: '$accents7' }}>
52+
{isNative ? 'Native' : balance.symbol}
53+
</Text>
54+
</div>
55+
<Text size={12} css={{ fontWeight: '600', color: '$success' }}>
56+
{balance.balanceFormatted} {balance.symbol}
57+
</Text>
58+
</div>
59+
)
60+
}
61+
62+
export default function BalanceOverviewCard({ chainId, address, onLoadingChange }: Props) {
63+
const [balances, setBalances] = useState<AllBalancesResult | null>(null)
64+
const [loading, setLoading] = useState(true)
65+
const [error, setError] = useState<string | null>(null)
66+
const fetchInProgress = useRef(false)
67+
68+
const chainData = getChainData(chainId) as { logo?: string } | undefined
69+
const chainLogo = chainData?.logo
70+
71+
const fetchBalance = useCallback(async () => {
72+
if (!address) {
73+
setLoading(false)
74+
setError('No address provided')
75+
onLoadingChange?.(false)
76+
return
77+
}
78+
79+
// Debounce: prevent concurrent fetches
80+
if (fetchInProgress.current) {
81+
return
82+
}
83+
84+
fetchInProgress.current = true
85+
setLoading(true)
86+
setError(null)
87+
onLoadingChange?.(true)
88+
89+
try {
90+
const result = await fetchAllBalances(address, chainId)
91+
setBalances(result)
92+
// Check if native balance has an error
93+
if (result.native.error) {
94+
setError(result.native.error)
95+
}
96+
} catch (err) {
97+
setError(err instanceof Error ? err.message : 'Failed to fetch balance')
98+
} finally {
99+
setLoading(false)
100+
fetchInProgress.current = false
101+
onLoadingChange?.(false)
102+
}
103+
}, [address, chainId, onLoadingChange])
104+
105+
useEffect(() => {
106+
fetchBalance()
107+
}, [fetchBalance])
108+
109+
if (loading) {
110+
return (
111+
<div style={styles.container}>
112+
<div style={styles.loadingContainer}>
113+
<Loading size="xs" />
114+
<Text size={12}>Loading balances...</Text>
115+
</div>
116+
</div>
117+
)
118+
}
119+
120+
if (error && !balances) {
121+
return (
122+
<div style={styles.container}>
123+
<div style={styles.errorContainer}>
124+
<Text size={12} css={{ color: '$error' }}>
125+
Unable to fetch balance
126+
</Text>
127+
<Button
128+
size="xs"
129+
css={{ minWidth: 'auto', fontSize: '11px' }}
130+
onClick={fetchBalance}
131+
>
132+
Retry
133+
</Button>
134+
</div>
135+
</div>
136+
)
137+
}
138+
139+
if (!balances) {
140+
return null
141+
}
142+
143+
return (
144+
<div style={styles.container}>
145+
<BalanceRow balance={{ ...balances.native, icon: chainLogo }} isNative />
146+
{balances.tokens.map(token => (
147+
<BalanceRow key={token.symbol} balance={token} />
148+
))}
149+
</div>
150+
)
151+
}

advanced/wallets/react-wallet-v2/src/components/ChainCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { ReactNode } from 'react'
44
interface Props {
55
children: ReactNode | ReactNode[]
66
rgb: string
7-
flexDirection: 'row' | 'col'
8-
alignItems: 'center' | 'flex-start'
7+
flexDirection: 'row' | 'col' | 'column'
8+
alignItems: 'center' | 'flex-start' | 'stretch'
99
flexWrap?: 'wrap' | 'nowrap'
1010
}
1111

advanced/wallets/react-wallet-v2/src/data/Bip122Data.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ export const BITCOIN_MAINNET = {
1616
name: 'BTC Mainnet',
1717
logo: '/chain-logos/btc-testnet.png',
1818
rgb: '107, 111, 147',
19-
rpc: '',
19+
rpc: 'https://blockstream.info/api',
2020
coinType: '0',
2121
caip2: BIP122_MAINNET_CAIP2 as IBip122ChainId,
22-
namespace: BIP122_NAMESPACE
22+
namespace: BIP122_NAMESPACE,
23+
symbol: 'BTC'
2324
}
2425
}
2526
export const BITCOIN_TESTNET = {
@@ -28,10 +29,11 @@ export const BITCOIN_TESTNET = {
2829
name: 'BTC Testnet',
2930
logo: '/chain-logos/btc-testnet.png',
3031
rgb: '247, 147, 25',
31-
rpc: '',
32+
rpc: 'https://blockstream.info/testnet/api',
3233
coinType: '1',
3334
caip2: BIP122_TESTNET_CAIP2 as IBip122ChainId,
34-
namespace: BIP122_NAMESPACE
35+
namespace: BIP122_NAMESPACE,
36+
symbol: 'BTC'
3537
}
3638
}
3739

advanced/wallets/react-wallet-v2/src/data/COSMOSData.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ export const COSMOS_MAINNET_CHAINS = {
1212
name: 'Cosmos Hub',
1313
logo: '/chain-logos/cosmos-cosmoshub-4.png',
1414
rgb: '107, 111, 147',
15-
rpc: '',
16-
namespace: 'cosmos'
15+
rpc: 'https://cosmos-rest.publicnode.com',
16+
namespace: 'cosmos',
17+
symbol: 'ATOM'
1718
}
1819
}
1920

0 commit comments

Comments
 (0)