fix: wallet QA fixes (#1020)

* fix: send modal input

* chore: update buy crypto drawer

* fix: date format in collectibles detail

* chore: regenerate token erc20 list with manually fixed BONK decimals

* fix: currency amount format

* fix: support ipfs icons and add fallback for missing icons

* fix: set minimal transactino value

* fix: improve balance validation in send assets modal

* fix: build

* Create blue-poems-mate.md

* chore: address feedback

* chore: update comment about wrapper BONK

* chore: remove redundant lowercase

* chore: remove log

* fix: rename minimum transaction value constant and update styling
This commit is contained in:
Jakub
2026-02-09 23:00:17 +01:00
committed by GitHub
parent 731bfcf601
commit b6eb66516d
12 changed files with 14770 additions and 8350 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@status-im/wallet": patch
---
fix: wallet QA fixes
@@ -14,6 +14,15 @@ const supportedNetworks = [
8453, // Base
]
/**
* Manual overrides for tokens with incorrect decimals in upstream sources.
* Key: checksummed contract address, Value: correct decimals
* @see https://etherscan.io/token/{address} to verify on-chain decimals
*/
const decimalOverrides = {
'0x1151CB3d861920e07a38e03eEAd12C32178567F6': 5, // Wrapped BONK (Ethereum ERC-20) - on-chain decimals on Ethereum is 5, not 18
}
const standardTokenLists = [
{ name: 'Uniswap', url: 'https://gateway.ipfs.io/ipns/tokens.uniswap.org' },
// may include lowercased addresses (e.g. 0x744d70fdbe2ba4cf95131626614a1763df805b9e instead of 0x744d70FDBE2Ba4CF95131626614a1763DF805B9E)
@@ -152,21 +161,26 @@ function generateTemplate(tokens) {
minor: 0,
patch: 0,
},
tokens: tokens.map(token => ({
chainId: token.chainId,
address: token.address,
name: token.name,
symbol: token.symbol,
decimals: token.decimals,
logoURI: token.logoURI,
...(token.extensions &&
token.extensions.bridgeInfo &&
Object.keys(token.extensions.bridgeInfo).length > 0 && {
extensions: {
bridgeInfo: token.extensions.bridgeInfo,
},
}),
})),
tokens: tokens.map(token => {
// Apply decimal overrides for tokens with incorrect upstream data
const decimals = decimalOverrides[token.address] ?? token.decimals
return {
chainId: token.chainId,
address: token.address,
name: token.name,
symbol: token.symbol,
decimals,
logoURI: token.logoURI,
...(token.extensions &&
token.extensions.bridgeInfo &&
Object.keys(token.extensions.bridgeInfo).length > 0 && {
extensions: {
bridgeInfo: token.extensions.bridgeInfo,
},
}),
}
}),
}
return JSON.stringify(tokenList, null, 2)
@@ -9,6 +9,7 @@ import { CurrencyAmount } from '../currency-amount'
import { PercentageChange } from '../percentage-change'
import * as Table from '../table'
import { TokenAmount } from '../token-amount'
import { TokenIcon } from '../token-icon'
import type { ApiOutput } from '../../data'
@@ -104,10 +105,11 @@ const AssetsList = (props: Props) => {
>
<Table.Cell size={36}>
<div className="flex flex-row items-center gap-2">
<img
className="size-6 rounded-full bg-neutral-10"
alt={asset.name}
src={asset.icon}
<TokenIcon
icon={asset.icon}
name={asset.name}
symbol={asset.symbol}
size="24"
/>
<span className="truncate">{asset.name}</span>
</div>
@@ -177,10 +179,11 @@ const AssetsList = (props: Props) => {
}}
>
<div className="flex items-center gap-2">
<img
className="size-8 rounded-full"
alt={asset.name}
src={asset.icon}
<TokenIcon
icon={asset.icon}
name={asset.name}
symbol={asset.symbol}
size="32"
/>
<div className="flex min-w-0 flex-col items-start text-left">
<span className="w-full max-w-40 truncate text-15 font-600 sm:max-w-full">
@@ -3,12 +3,15 @@
import { useState } from 'react'
import { Avatar, useToast } from '@status-im/components'
import { FeesIcon } from '@status-im/icons/12'
import { ExternalIcon } from '@status-im/icons/20'
import { ERROR_MESSAGES } from '../../constants'
import * as Drawer from '../drawer'
import { Image, type ImageId } from '../image'
import {
getProviderUrl,
ProviderCard,
type ProviderInfo,
PROVIDERS,
} from '../provider-card'
import type { Account } from '../address'
@@ -25,26 +28,19 @@ export type Currency = {
type NetworkOptions = Array<{
id: 'ETHEREUM'
name: string
image: ImageId
}>
const NETWORKS: NetworkOptions = [
{
id: 'ETHEREUM',
name: 'Ethereum',
image: 'Wallet/Icons/Logos/01:120:120',
},
]
export type BuyCryptoDrawerProps = {
children: React.ReactElement
account?: Account
providers?: Array<{
name: string
description: string
fee: string
image: ImageId
}>
providers?: ProviderInfo[]
onProviderSelect?: (
provider: Provider,
network: string,
@@ -60,20 +56,7 @@ export const BuyCryptoDrawer = (props: Props) => {
const {
children,
account,
providers = [
{
name: 'mercuryo',
// description: 'Buy crypto within 15 seconds',
fee: '1% - 4.5%',
image: 'Wallet/Icons/Logos/mercuryo:64:64',
},
{
name: 'moonpay',
// description: 'The new standard for fiat to crypto',
fee: '1% - 4.5%',
image: 'Wallet/Icons/Logos/moonpay:64:64',
},
],
providers = PROVIDERS,
onProviderSelect,
onOpenTab,
symbol,
@@ -101,12 +84,11 @@ export const BuyCryptoDrawer = (props: Props) => {
const data = await onProviderSelect(provider, network.id, currency.code)
url = data.url
} else {
const providerUrls = {
mercuryo: `https://exchange.mercuryo.io/?type=buy&network=${network.id}&currency=${currency.code}&address=${account.address}&hide_address=false&fix_address=true&widget_id=6a7eb330-2b09-49b7-8fd3-1c77cfb6cd47`,
moonpay: `https://buy.moonpay.com?apiKey=pk_live_YQC6CQPA5qqDu0unEwHJyAYQyeIqFGR`,
}
url = getProviderUrl(provider, account.address, {
network: network.id,
currency: currency.code,
})
url = providerUrls[provider]
if (!url) {
toast.negative(ERROR_MESSAGES.PROVIDER_NOT_SUPPORTED)
return
@@ -165,43 +147,14 @@ export const BuyCryptoDrawer = (props: Props) => {
</Drawer.Header>
<Drawer.Body className="relative flex flex-col overflow-clip">
<div className="mt-2 flex flex-col gap-0.5 rounded-16 border border-neutral-10 bg-neutral-2.5 p-1">
{providers.map(provider => {
return (
<button
key={provider.name}
className="flex w-full cursor-pointer items-center justify-between gap-4 rounded-12 px-2 py-1 transition-colors hover:bg-neutral-5"
onClick={() =>
handleProviderSelect(provider.name as Provider)
}
>
<div className="flex items-center gap-2">
<Image
id={provider.image}
alt={provider.name}
className="size-8 rounded-full"
/>
<div className="flex flex-col">
<div className="flex flex-col items-start">
<div className="text-15 font-600 capitalize">
{provider.name}
</div>
{/* <div className="text-13 text-neutral-50">
{provider.description}
</div> */}
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1 text-13 font-500">
<FeesIcon /> {provider.fee}
</div>
<ExternalIcon />
</div>
</button>
)
})}
<div className="mt-2 flex flex-col gap-3">
{providers.map(provider => (
<ProviderCard
key={provider.name}
{...provider}
onClick={() => handleProviderSelect(provider.name as Provider)}
/>
))}
</div>
</Drawer.Body>
</Drawer.Content>
@@ -21,6 +21,9 @@ const MIN_VALUE = 0.01
export const CurrencyAmount = (props: Props) => {
const { value, format = 'standard', className } = props
const factor = 10 ** 4
const amount = Math.floor(value * factor) / factor
const formatter = useMemo(
() =>
match(format)
@@ -62,9 +65,9 @@ export const CurrencyAmount = (props: Props) => {
[format],
)
if (value > 0 && value < MIN_VALUE && format !== 'precise') {
if (amount > 0 && amount < MIN_VALUE && format !== 'precise') {
return <div className={className}>{'< ' + formatter.format(MIN_VALUE)}</div>
}
return <div className={className}>{formatter.format(value)}</div>
return <div className={className}>{formatter.format(amount)}</div>
}
@@ -1,7 +1,4 @@
import { ExternalIcon } from '@status-im/icons/12'
import { cx } from 'class-variance-authority'
import { Image, type ImageId } from '../../image'
import { getProviderUrl, ProviderCard, PROVIDERS } from '../../provider-card'
type Props = {
address: string
@@ -10,29 +7,6 @@ type Props = {
const BuyTokens = (props: Props) => {
const { address } = props
const PROVIDERS: ProviderProps[] = [
{
name: 'MoonPay',
image: 'Wallet/Icons/Logos/moonpay-bigger:144:144',
list: [
'Pay with Credit/Debit Card, Bank Transfer, Apple/Google Pay, SEPA, +9 more',
'Fees: from 1%',
'Supported Countries: 180',
],
url: 'https://buy.moonpay.com/v2/buy?apiKey=pk_live_YQC6CQPA5qqDu0unEwHJyAYQyeIqFGR',
},
{
name: 'Mercuryo',
image: 'Wallet/Icons/Logos/mercuryo-bigger:144:144',
list: [
'Pay with Credit/Debit Card, Bank Transfer, Apple/Google Pay, SEPA, +10 more',
'Fees: from 1%',
'Supported Countries: 135+',
],
url: `https://exchange.mercuryo.io/?type=buy&network=ETHEREUM&currency=ETH&address=${address}&hide_address=false&fix_address=true&widget_id=6a7eb330-2b09-49b7-8fd3-1c77cfb6cd47`,
},
]
return (
<div className="flex flex-col items-start justify-start gap-4 rounded-16 bg-customisation-blue-50/5">
<div className="flex flex-col gap-1 p-4 pb-1">
@@ -45,55 +19,15 @@ const BuyTokens = (props: Props) => {
</div>
<div className="flex gap-3 p-3">
{PROVIDERS.map(provider => (
<Provider key={provider.name} {...provider} />
<ProviderCard
key={provider.name}
{...provider}
href={getProviderUrl(provider.name, address)}
/>
))}
</div>
</div>
)
}
type ProviderProps = {
name: string
image: ImageId
list: string[]
url: string
}
const Provider = (props: ProviderProps) => {
const { name, image, list, url } = props
return (
<div className="flex flex-col items-center justify-center gap-2 rounded-16 bg-white-100 p-4">
<div className="flex flex-col items-center justify-center gap-2">
<Image id={image} className="size-[72px]" />
<h2 className="text-15 font-600">{name}</h2>
</div>
<ul className="mt-2 items-start text-13/[21px] font-400 text-neutral-50">
{list.map(item => (
<li key={item} className="ml-3 list-disc">
{item}
</li>
))}
</ul>
<div className="flex items-center justify-center">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={cx(
'inline-flex h-6 shrink-0 cursor-pointer items-center justify-center gap-1',
'rounded-8 border border-neutral-30 bg-transparent px-2 pr-[6px] text-13 font-500 text-neutral-100 outline-none transition-all',
'active:border-neutral-20 active:bg-neutral-10 active:text-neutral-100 hover:border-neutral-40',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-80 focus-visible:ring-offset-2',
'disabled:cursor-default disabled:opacity-[.3]',
)}
>
Go to {name}
<ExternalIcon className="shrink-0 text-neutral-50 [&>svg]:size-full" />
</a>
</div>
</div>
)
}
export { BuyTokens }
@@ -0,0 +1,109 @@
import { ExternalIcon } from '@status-im/icons/12'
import { cx } from 'class-variance-authority'
import { Image, type ImageId } from '../image'
export type ProviderInfo = {
name: string
image: ImageId
list: string[]
}
export const PROVIDERS: ProviderInfo[] = [
{
name: 'MoonPay',
image: 'Wallet/Icons/Logos/moonpay-bigger:144:144',
list: [
'Pay with Credit/Debit Card, Bank Transfer, Apple/Google Pay, SEPA, +9 more',
'Fees: from 1%',
'Supported Countries: 180',
],
},
{
name: 'Mercuryo',
image: 'Wallet/Icons/Logos/mercuryo-bigger:144:144',
list: [
'Pay with Credit/Debit Card, Bank Transfer, Apple/Google Pay, SEPA, +10 more',
'Fees: from 1%',
'Supported Countries: 135+',
],
},
]
export const getProviderUrl = (
provider: string,
address?: string,
options?: { network?: string; currency?: string },
): string => {
const { network = 'ETHEREUM', currency = 'ETH' } = options ?? {}
const urls: Record<string, string> = {
moonpay:
'https://buy.moonpay.com?apiKey=pk_live_YQC6CQPA5qqDu0unEwHJyAYQyeIqFGR',
mercuryo: `https://exchange.mercuryo.io/?type=buy&network=${network}&currency=${currency}&address=${address}&hide_address=false&fix_address=true&widget_id=6a7eb330-2b09-49b7-8fd3-1c77cfb6cd47`,
}
return urls[provider.toLowerCase()] ?? ''
}
type ProviderCardProps = ProviderInfo & {
href?: string
onClick?: () => void
}
export const ProviderCard = (props: ProviderCardProps) => {
const { name, image, list, href, onClick } = props
const cardClassName =
'flex flex-col items-center gap-2 rounded-16 border border-neutral-10 bg-white-100 p-4'
const content = (
<>
<Image id={image} className="size-[72px]" />
<h2 className="text-15 font-600">{name}</h2>
<ul className="mt-2 w-full text-13/[21px] font-400 text-neutral-50">
{list.map(item => (
<li key={item} className="ml-3 list-disc text-left">
{item}
</li>
))}
</ul>
</>
)
if (onClick) {
return (
<button
onClick={onClick}
className={cx(
cardClassName,
'cursor-pointer transition-colors hover:bg-neutral-5',
)}
>
{content}
</button>
)
}
return (
<div className={cardClassName}>
{content}
<div className="flex items-center justify-center">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={cx(
'inline-flex h-6 shrink-0 cursor-pointer items-center justify-center gap-1',
'rounded-8 border border-neutral-30 bg-transparent px-2 pr-[6px] text-13 font-500 text-neutral-100 outline-none transition-all',
'active:border-neutral-20 active:bg-neutral-10 active:text-neutral-100 hover:border-neutral-40',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-80 focus-visible:ring-offset-2',
)}
>
Go to {name}
<ExternalIcon className="shrink-0 text-neutral-50" />
</a>
</div>
</div>
)
}
@@ -14,6 +14,7 @@ import { CurrencyAmount } from '../currency-amount'
import { NetworkLogo } from '../network-logo'
import { PasswordModal } from '../password-modal'
import { TokenAmount } from '../token-amount'
import { TokenIcon } from '../token-icon'
import type { NetworkType } from '../../data'
import type { Account } from '../address'
@@ -26,7 +27,7 @@ type Props = {
}
asset: {
name: string
icon: string
icon?: string
symbol: string
totalBalance: number
totalBalanceEur: number
@@ -70,18 +71,18 @@ const createFormSchema = (
.refine(
val => {
const amount = Number.parseFloat(val)
if (!maxGasFeeEth) return amount > 0 && amount <= balance
const isETH = assetSymbol === 'ETH'
const totalCost = isETH ? amount + maxGasFeeEth : maxGasFeeEth
const availableBalance = balance
return amount > 0 && totalCost <= availableBalance
return amount > 0 && amount <= balance
},
{ message: 'More than available balance' },
)
.refine(
val => {
if (!maxGasFeeEth || assetSymbol !== 'ETH') return true
const amount = Number.parseFloat(val)
return amount + maxGasFeeEth <= balance
},
{
message: maxGasFeeEth
? `Insufficient balance. Max gas fees: ${maxGasFeeEth.toFixed(6)} ETH`
: 'More than available balance',
message: `Insufficient balance. Max gas fees: ${(maxGasFeeEth ?? 0).toFixed(6)} ETH`,
},
),
contractAddress: z
@@ -150,6 +151,19 @@ const SendAssetsModal = (props: Props) => {
const watchedTo = watch('to')
const balanceEur = asset.totalBalanceEur
// Minimum transaction value in USD
const MIN_TRANSACTION_AMOUNT_USD = 0.1
const amountFiatValue = useMemo(() => {
if (!watchedAmount || balance === 0) return 0
return Number.parseFloat(watchedAmount || '0') * (balanceEur / balance)
}, [watchedAmount, balance, balanceEur])
const hasValueBelowMinimum =
watchedAmount &&
Number.parseFloat(watchedAmount) > 0 &&
amountFiatValue < MIN_TRANSACTION_AMOUNT_USD
useEffect(() => {
setValue('contractAddress', asset.contractAddress || undefined)
}, [asset.contractAddress, setValue])
@@ -284,7 +298,7 @@ const SendAssetsModal = (props: Props) => {
data-customisation="blue"
className="fixed left-0 top-[38px] flex size-full justify-center"
>
<div className="shadow opacity-100 fixed z-auto flex h-[calc(100vh-76px)] w-[calc(100%-23px)] max-w-[494px] flex-col gap-3 overflow-auto rounded-16 bg-white-100 transition data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in">
<div className="shadow fixed z-auto flex h-[calc(100vh-76px)] w-[calc(100%-23px)] max-w-[494px] flex-col gap-3 overflow-auto rounded-16 bg-white-100 opacity-[100] transition data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in">
<div className="flex items-center justify-between p-4">
<Dialog.Title className="text-27 font-semibold">
Send assets
@@ -305,7 +319,7 @@ const SendAssetsModal = (props: Props) => {
<form
onSubmit={handleSubmit(onSubmit)}
className="flex h-full flex-col place-content-between content-between justify-between space-y-6 px-4 pb-4"
className="flex h-full flex-col place-content-between space-y-6 px-4 pb-4"
>
<div>
<div className="mb-2 mt-4">
@@ -356,7 +370,7 @@ const SendAssetsModal = (props: Props) => {
step="any"
placeholder="0"
className={cx([
'w-full px-4 py-3 text-27 font-medium',
'w-full py-3 pl-4 pr-24 text-27 font-medium',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
hasInsufficientBalance && 'text-danger-50',
])}
@@ -364,12 +378,13 @@ const SendAssetsModal = (props: Props) => {
)}
/>
<div className="absolute right-4 top-3 flex items-center gap-2">
<div className="absolute right-4 top-3 flex items-center gap-2 bg-white-100">
<div className="relative">
<img
className="size-8 rounded-full"
alt={asset.name}
src={asset.icon}
<TokenIcon
icon={asset.icon}
name={asset.name}
symbol={asset.symbol}
size="32"
/>
<div className="absolute bottom-[-3px] right-[-3px] rounded-full border-2 border-white-100">
<NetworkLogo name={asset.network} size={12} />
@@ -408,12 +423,25 @@ const SendAssetsModal = (props: Props) => {
</div>
{errors.amount && hasInsufficientBalance && (
<div className="mt-2 flex items-center gap-1 text-13 text-danger-50">
<div className="mt-2 flex items-start gap-1 text-13 text-danger-50">
<AlertIcon className="size-4" />
<p>{errors.amount.message}</p>
</div>
)}
{hasValueBelowMinimum && (
<div className="mt-2 flex items-start gap-1 text-13 text-danger-50">
<AlertIcon className="size-4" />
<p>
Value of the transaction must be at least{' '}
<CurrencyAmount
value={MIN_TRANSACTION_AMOUNT_USD}
format="standard"
/>
</p>
</div>
)}
{watchedAmount && !hasInsufficientBalance && (
<div className="mt-2 flex items-center gap-1 text-13 font-medium text-neutral-50">
Remaining ~{' '}
@@ -557,6 +585,7 @@ const SendAssetsModal = (props: Props) => {
disabled={
hasInsufficientBalance ||
hasInsufficientEth ||
hasValueBelowMinimum ||
!watchedAmount ||
!watchedTo ||
!gasFees ||
@@ -0,0 +1,44 @@
import { cva, cx } from 'class-variance-authority'
type Props = {
icon?: string
name: string
symbol: string
size: '24' | '32'
}
const tokenIconStyles = cva('rounded-full bg-neutral-10', {
variants: {
size: {
'24': 'size-6 text-11',
'32': 'size-8 text-13',
},
},
})
function resolveIconUrl(icon: string): string {
if (icon.startsWith('ipfs://')) {
const cid = icon.replace('ipfs://', '')
return `https://ipfs.io/ipfs/${cid}`
}
return icon
}
export function TokenIcon({ icon, name, symbol, size }: Props) {
const initial = (symbol || name || '?').charAt(0).toUpperCase()
if (icon) {
const src = resolveIconUrl(icon)
return <img src={src} alt={name} className={tokenIconStyles({ size })} />
}
return (
<div
className={cx([
'flex items-center justify-center bg-neutral-20',
tokenIconStyles({ size }),
])}
>
<span className="font-semibold text-neutral-40">{initial}</span>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -41,7 +41,7 @@ const tokenMetadataSchema = z.object({
type Asset = {
networks: NetworkType[]
icon: string
icon?: string
name: string
symbol: string
price_eur: number
@@ -105,7 +105,7 @@ export const DEFAULT_TOKEN_IDS: Record<string, string> = {
function buildTokenSummary(
assets: Asset[],
defaultIcon: string,
defaultIcon: string | undefined,
defaultName: string,
defaultSymbol: string,
) {
@@ -114,7 +114,7 @@ function buildTokenSummary(
return {
...summary,
icon: defaultIcon,
icon: defaultIcon ?? '',
name: defaultName,
symbol: defaultSymbol,
about: firstAsset?.metadata.about ?? '',
@@ -674,7 +674,7 @@ async function token({
if (
!Number(result.tokenBalances[0].tokenBalance) &&
!DEFAULT_TOKEN_SYMBOLS.includes(token.symbol)
!(DEFAULT_TOKEN_SYMBOLS as readonly string[]).includes(token.symbol)
) {
throw new Error(`Balance not found for token ${token.symbol}`)
}
@@ -1,5 +1,6 @@
import { cache } from 'react'
import { format } from 'date-fns'
import { z } from 'zod'
import {
@@ -418,7 +419,22 @@ function map(
traits: Array.isArray(attributes)
? attributes.reduce(
(acc, attribute) => {
acc[attribute.trait_type] = attribute.value
if (
'display_type' in attribute &&
attribute.display_type === 'date' &&
typeof attribute.value === 'number'
) {
const timestamp =
attribute.value < 10000000000
? attribute.value * 1000
: attribute.value
acc[attribute.trait_type] = format(
new Date(timestamp),
'dd MMM yyyy',
)
} else {
acc[attribute.trait_type] = attribute.value
}
return acc
},
{} as Record<string, string | number | boolean>,