diff --git a/src/locales/en.json b/src/locales/en.json index 5855d93a9..216185150 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -431,6 +431,7 @@ "cell": "Cell", "cells": "Cells", "rgb_plus_plus": "RGB++", + "invalid": "Invalid", "inscription": "Inscription", "confirmation": "Confirmation", "confirmations": "Confirmations", diff --git a/src/locales/zh.json b/src/locales/zh.json index abae38911..78ea3cc77 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -432,6 +432,7 @@ "cells": "Cells", "inscription": "铭文", "rgb_plus_plus": "RGB++", + "invalid": "Invalid", "confirmation": "确认区块", "confirmations": "确认区块", "unable_decode_address": "地址解析失败", diff --git a/src/pages/Address/BTCAddressComp.tsx b/src/pages/Address/BTCAddressComp.tsx index 2da007ace..16ea25b80 100644 --- a/src/pages/Address/BTCAddressComp.tsx +++ b/src/pages/Address/BTCAddressComp.tsx @@ -1,16 +1,19 @@ import { useState, FC, useEffect } from 'react' import { useTranslation } from 'react-i18next' +import { useQuery } from '@tanstack/react-query' import { AddressAssetsTab, AddressAssetsTabPane, AddressAssetsTabPaneTitle, AddressUDTAssetsPanel } from './styled' import styles from './styles.module.scss' import { Address, UDTAccount } from '../../models/Address' import { Card } from '../../components/Card' import Cells from './Cells' import RgbppAssets from './RgbppAssets' +import { explorerService } from '../../services/ExplorerService' // import RgbppAssets from './RgbppAssets' enum AssetInfo { CELLs, RGBPP, + Invalid, } export const BTCAddressOverviewCard: FC<{ address: Address }> = ({ address }) => { @@ -18,6 +21,11 @@ export const BTCAddressOverviewCard: FC<{ address: Address }> = ({ address }) => const { udtAccounts = [] } = address const [activeTab, setActiveTab] = useState(AssetInfo.RGBPP) + const { data } = useQuery(['bitcoin addresses', address], () => + explorerService.api.fetchBitcoinAddresses(address.bitcoinAddressHash || ''), + ) + const { boundLiveCellsCount, unboundLiveCellsCount } = data || { boundLiveCellsCount: 0, unboundLiveCellsCount: 0 } + const [udts, inscriptions] = udtAccounts.reduce( (acc, cur) => { switch (cur?.udtType) { @@ -50,7 +58,8 @@ export const BTCAddressOverviewCard: FC<{ address: Address }> = ({ address }) => ) const hasAssets = udts.length || inscriptions.length - const hasCells = +address.liveCellsCount > 0 + const hasCells = boundLiveCellsCount > 0 + const hasInvalid = unboundLiveCellsCount > 0 useEffect(() => { if (hasAssets) { @@ -105,6 +114,27 @@ export const BTCAddressOverviewCard: FC<{ address: Address }> = ({ address }) => ) : null} + {hasInvalid ? ( + setActiveTab(AssetInfo.Invalid)}> + {t('address.invalid')} + + } + key={AssetInfo.Invalid} + > +
+ +
+
+ ) : null} ) : null} diff --git a/src/pages/Address/InvalidRGBPPAssetList.module.scss b/src/pages/Address/InvalidRGBPPAssetList.module.scss new file mode 100644 index 000000000..8c7ffd629 --- /dev/null +++ b/src/pages/Address/InvalidRGBPPAssetList.module.scss @@ -0,0 +1,126 @@ +@import '../../styles/variables.module'; + +.container { + ul { + list-style: none; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(248px, 1fr)); + gap: 1rem; + font-weight: 500; + } + + .card { + min-height: 86px; + background: #fff; + border-radius: 4px; + overflow: hidden; + + h5 { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--primary-color); + color: #fff; + height: 1.875rem; + padding: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + a { + &:hover { + font-weight: bold; + color: #fff; + text-decoration: underline; + } + } + + .copy { + appearance: none; + border: none; + background: none; + width: 14px; + cursor: pointer; + margin-right: 8px; + height: 14px; + + svg { + pointer-events: none; + height: 14px; + } + } + + span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + cursor: default; + flex: 1; + text-align: right; + } + } + + .itemContent { + padding: 8px; + display: flex; + line-height: 1; + + img, + svg { + margin-right: 8px; + } + + .assetName { + font-weight: 500; + } + + .attribute { + display: flex; + } + + .copy { + appearance: none; + border: none; + background: none; + width: 14px; + cursor: pointer; + + &:hover { + svg { + stroke: var(--primary-color); + } + } + + svg { + pointer-events: none; + height: 14px; + } + } + + .fields { + display: flex; + flex-direction: column; + justify-content: space-around; + } + } + } +} + +.loading { + display: flex; + justify-content: center; + padding: 0.5rem; +} + +.loadMore { + display: flex; + justify-content: center; + padding: 0.5rem; + + button { + cursor: pointer; + appearance: none; + border: none; + } +} diff --git a/src/pages/Address/InvalidRGBPPAssetList.tsx b/src/pages/Address/InvalidRGBPPAssetList.tsx new file mode 100644 index 000000000..feecc341b --- /dev/null +++ b/src/pages/Address/InvalidRGBPPAssetList.tsx @@ -0,0 +1,323 @@ +import { type FC, useState, useRef, useEffect } from 'react' +import { TFunction, useTranslation } from 'react-i18next' +import BigNumber from 'bignumber.js' +import { useInfiniteQuery } from '@tanstack/react-query' +import { explorerService, LiveCell } from '../../services/ExplorerService' +import SUDTTokenIcon from '../../assets/sudt_token.png' +import CKBTokenIcon from './ckb_token_icon.png' +import { ReactComponent as CopyIcon } from './copy.svg' +import { ReactComponent as TypeHashIcon } from './type_script.svg' +import { ReactComponent as DataIcon } from './data.svg' +import { ReactComponent as SporeCluterIcon } from './spore_cluster.svg' +import { ReactComponent as SporeCellIcon } from './spore_cell.svg' +import SmallLoading from '../../components/Loading/SmallLoading' +import { parseUDTAmount } from '../../utils/number' +import { getContractHashTag, shannonToCkb } from '../../utils/util' +import { useSetToast } from '../../components/Toast' +import { PAGE_SIZE } from '../../constants/common' +import styles from './InvalidRGBPPAssetList.module.scss' +import { CellBasicInfo } from '../../utils/transformer' + +const fetchCells = async ({ + address, + size = 10, + sort = 'capacity.desc', + page = 1, + boundStatus = 'bound', +}: { + boundStatus: 'bound' | 'unbound' + address: string + size: number + sort: string + page: number +}) => { + const res = await explorerService.api.fetchAddressLiveCells(address, page, size, sort, boundStatus) + return { + data: res.data, + nextPage: page + 1, + } +} + +const initialPageParams = { size: 10, sort: 'capacity.desc' } + +const ATTRIBUTE_LENGTH = 18 + +const getCellDetails = (cell: LiveCell, t: TFunction) => { + const ckb = new BigNumber(shannonToCkb(+cell.capacity)).toFormat() + const link = `/transaction/${cell.txHash}?${new URLSearchParams({ + page_of_outputs: Math.ceil((+cell.cellIndex + 1) / PAGE_SIZE).toString(), + })}` + const assetType: string = cell.extraInfo?.type ?? cell.cellType + let icon: string | React.ReactElement | null = null + let assetName = null + let attribute = null + let detailInfo = null + + const utxo = `${cell.txHash.slice(0, 4)}...${cell.txHash.slice(-3)}:${cell.cellIndex}` + + switch (assetType) { + case 'ckb': { + if (cell.typeHash) { + icon = + assetName = 'UNKNOWN ASSET' + attribute = `TYPE HASH: ${cell.typeHash.slice(0, 10)}...` + detailInfo = cell.typeHash + break + } + if (cell.data !== '0x') { + // TODO: indicate this is a contentful cell + icon = + assetName = 'DATA' + if (cell.data.length > ATTRIBUTE_LENGTH) { + attribute = `${cell.data.slice(0, ATTRIBUTE_LENGTH)}...` + } else { + attribute = cell.data + } + detailInfo = cell.data + break + } + icon = CKBTokenIcon + assetName = 'CKB' + attribute = ckb + detailInfo = BigNumber(cell.capacity).toFormat({ groupSeparator: '' }) + break + } + case 'nervos_dao_deposit': + case 'nervos_dao_withdrawing': { + icon = CKBTokenIcon + assetName = assetType === 'nervos_dao_deposit' ? 'Nervos DAO' : 'Nervos DAO Withdrawing' + attribute = ckb + detailInfo = BigNumber(cell.capacity).toFormat({ groupSeparator: '' }) + break + } + case 'udt': { + icon = SUDTTokenIcon + assetName = cell.extraInfo.symbol || 'UDT' + attribute = cell.extraInfo.decimal + ? parseUDTAmount(cell.extraInfo.amount, cell.extraInfo.decimal) + : 'Unknown UDT amount' + detailInfo = cell.extraInfo.amount + break + } + case 'xudt_compatible': { + icon = SUDTTokenIcon + assetName = cell.extraInfo?.symbol || 'xUDT-compatible' + attribute = + cell.extraInfo?.decimal && cell.extraInfo?.amount + ? parseUDTAmount(cell.extraInfo.amount, cell.extraInfo.decimal) + : 'Unknown xUDT amount' + detailInfo = cell.extraInfo?.amount + break + } + case 'xudt': { + icon = SUDTTokenIcon + assetName = cell.extraInfo?.symbol || 'xUDT' + attribute = + cell.extraInfo?.decimal && cell.extraInfo?.amount + ? parseUDTAmount(cell.extraInfo.amount, cell.extraInfo.decimal) + : 'Unknown xUDT amount' + detailInfo = cell.extraInfo?.amount + break + } + case 'omiga_inscription': { + icon = SUDTTokenIcon + assetName = cell.extraInfo.symbol || t('udt.inscription') + attribute = cell.extraInfo.decimal + ? parseUDTAmount(cell.extraInfo.amount, cell.extraInfo.decimal) + : 'Unknown amount' + detailInfo = cell.extraInfo.amount + break + } + case 'spore_cell': { + icon = + assetName = 'DOB' + if (cell.data.length > ATTRIBUTE_LENGTH) { + attribute = `${cell.data.slice(0, ATTRIBUTE_LENGTH)}...` + } else { + attribute = cell.data + } + detailInfo = cell.data + break + } + case 'spore_cluster': { + icon = + assetName = 'Spore Cluster' + if (cell.data.length > ATTRIBUTE_LENGTH) { + attribute = `${cell.data.slice(0, ATTRIBUTE_LENGTH)}...` + } else { + attribute = cell.data + } + detailInfo = cell.data + break + } + case 'nrc_721': { + icon = SUDTTokenIcon + assetName = 'NRC 721' + attribute = '-' + break + } + case 'm_nft': { + icon = SUDTTokenIcon + assetName = cell.extraInfo.className + attribute = `#${parseInt(cell.extraInfo.tokenId, 16)}` + break + } + default: { + icon = SUDTTokenIcon + assetName = 'UNKNOWN' + attribute = '-' + } + } + + const cellInfo = { + ...cell, + cellIndex: cell.cellIndex.toString(), + id: Number(cell.cellId), + isGenesisOutput: false, + generatedTxHash: '', + status: 'live', + consumedTxHash: '', + } as CellBasicInfo + + return { + link, + assetType, + ckb, + detailInfo, + icon, + assetName, + attribute, + cellInfo, + utxo, + } +} + +const AssetItem: FC<{ cell: LiveCell }> = ({ cell }) => { + const setToast = useSetToast() + const { t } = useTranslation() + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation() + e.preventDefault() + const { detail } = e.currentTarget.dataset + if (!detail) return + navigator.clipboard.writeText(detail).then(() => { + setToast({ message: t('common.copied') }) + }) + } + + const { link, assetType, ckb, detailInfo, icon, assetName, attribute } = getCellDetails(cell, t) + + return ( +
  • +
    + {t(`transaction.${assetType}`)} + + {`${ckb} CKB`} +
    +
    + {typeof icon === 'string' ? {assetName : null} + {icon && typeof icon !== 'string' ? icon : null} +
    +
    {assetName}
    +
    + {attribute} + {detailInfo ? ( + + ) : null} +
    +
    +
    +
  • + ) +} + +const InvalidRGBPPAssetList: FC<{ + address: string + count: number +}> = ({ address, count }) => { + const [params] = useState(initialPageParams) + const loadMoreRef = useRef(null) + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery( + ['address live cells', address, params.size, params.sort, 'unbound'], + ({ pageParam = 1 }) => fetchCells({ ...params, address, page: pageParam, boundStatus: 'unbound' }), + { + getNextPageParam: (lastPage: any) => { + if (lastPage.data.length < params.size) return false + return lastPage.nextPage + }, + }, + ) + + const isListDisplayed = count && data + + useEffect(() => { + const trigger = loadMoreRef.current + + if (!isListDisplayed) return + const observer = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting) { + fetchNextPage() + } + }, + { threshold: 0.5 }, + ) + if (trigger) { + observer.observe(trigger) + } + return () => { + if (trigger) { + observer.unobserve(trigger) + } + } + }, [isListDisplayed, fetchNextPage]) + + const cells = + data?.pages + .map(page => page.data) + .flat() + .filter(cell => { + const info = getContractHashTag(cell.lockScript) + return info?.tag === 'RGB++' + }) + .reduce((acc, cur) => { + // remove repeated cells + if (acc.find(c => c.txHash === cur.txHash && c.cellIndex === cur.cellIndex)) { + return acc + } + return [...acc, cur] + }, [] as LiveCell[]) ?? [] + + return ( +
    +
      + {cells.map(cell => ( + + ))} +
    + {isFetchingNextPage ? ( + + + + ) : null} + {!hasNextPage || isFetchingNextPage ? null : ( +
    + +
    + )} +
    + ) +} + +export default InvalidRGBPPAssetList diff --git a/src/pages/Address/RgbppAssets.tsx b/src/pages/Address/RgbppAssets.tsx index 2f191f42a..a60cfeb54 100644 --- a/src/pages/Address/RgbppAssets.tsx +++ b/src/pages/Address/RgbppAssets.tsx @@ -30,6 +30,7 @@ import { CellBasicInfo } from '../../utils/transformer' import { isTypeIdScript } from '../../utils/typeid' import config from '../../config' import { getBtcChainIdentify } from '../../services/BTCIdentifier' +import InvalidRGBPPAssetList from './InvalidRGBPPAssetList' const fetchCells = async ({ address, @@ -508,12 +509,13 @@ const RGBAssetsTableView: FC<{ address: string; count: number }> = ({ address, c ) } -const RgbAssets: FC<{ address: string; count: number; udts: UDTAccount[]; inscriptions: UDTAccount[] }> = ({ - address, - count, - udts, - inscriptions, -}) => { +const RgbAssets: FC<{ + address: string + count: number + udts: UDTAccount[] + inscriptions: UDTAccount[] + isUnBounded?: boolean +}> = ({ address, count, udts, inscriptions, isUnBounded }) => { const fontSize = 14 const minWidth = 250 const [isMerged, setIsMerged] = useState(true) @@ -532,31 +534,38 @@ const RgbAssets: FC<{ address: string; count: number; udts: UDTAccount[]; inscri return (
    -
    {t(`address.${isMerged ? 'view-as-merged-assets' : 'view-as-asset-items'}`)}
    -
    - - - - - - -
    + {!isUnBounded && ( + <> +
    {t(`address.${isMerged ? 'view-as-merged-assets' : 'view-as-asset-items'}`)}
    +
    + + + + + + +
    + + )}
    @@ -564,10 +573,16 @@ const RgbAssets: FC<{ address: string; count: number; udts: UDTAccount[]; inscri ) : ( <> - {isMerged ? ( + {isMerged && !isUnBounded ? ( ) : ( - + <> + {isUnBounded ? ( + + ) : ( + + )} + )} )} diff --git a/src/services/ExplorerService/fetcher.ts b/src/services/ExplorerService/fetcher.ts index bc2e4851e..a97888b7e 100644 --- a/src/services/ExplorerService/fetcher.ts +++ b/src/services/ExplorerService/fetcher.ts @@ -16,6 +16,7 @@ import { TransactionRecord, LiveCell, TokenCollection, + BitcoinAddresses, } from './types' import { assert } from '../../utils/error' import { Cell } from '../../models/Cell' @@ -170,9 +171,16 @@ export const apiFetcher = { // sort field, block_timestamp, capacity // sort type, asc, desc - fetchAddressLiveCells: (address: string, page: number, size: number, sort?: string) => { + fetchAddressLiveCells: ( + address: string, + page: number, + size: number, + sort?: string, + boundStatus: 'bound' | 'unbound' = 'bound', + ) => { return v1GetUnwrappedPagedList(`address_live_cells/${address}`, { params: { + bound_status: boundStatus, page, page_size: size, sort, @@ -243,6 +251,9 @@ export const apiFetcher = { .get(`ckb_transactions/${hash}/rgb_digest`) .then(res => toCamelcase>(res.data)), + fetchBitcoinAddresses: (address: string) => + requesterV2.get(`bitcoin_addresses/${address}`).then(res => toCamelcase(res.data)), + fetchCellsByTxHash: (hash: string, type: 'inputs' | 'outputs', page: Record<'no' | 'size', number>) => requesterV2 .get(`ckb_transactions/${hash}/display_${type}`, { diff --git a/src/services/ExplorerService/types.ts b/src/services/ExplorerService/types.ts index 488e329b5..8c07c87ed 100644 --- a/src/services/ExplorerService/types.ts +++ b/src/services/ExplorerService/types.ts @@ -336,6 +336,11 @@ export interface RGBDigest { transfers: TransactionRecord[] } +export interface BitcoinAddresses { + unboundLiveCellsCount: number + boundLiveCellsCount: number +} + export namespace RawBtcRPC { interface Utxo { value: number