Skip to content

Commit

Permalink
feat: support spore nft (#2810)
Browse files Browse the repository at this point in the history
* feat: support spore nft

* feat: display cluster name in asset list

* chore: update lockfile

* fix: avoid circular dep

* fix: avoid circular dep

* refactor: rm unused devnet config of spore

* refactor: rm unused spore's unbound mark

* refactor: rm unsuitable button type

* chore: fix dep version

* chore: reason for rpc is required when getCustomizedAsset

* test: make sporeFormatter testable

* refactor: switch-case to more clear

* chore: update lock file

* chore: update lock file

* feat: copyable spore info

* feat: display the cluster's description

* feat: support sending spore

* fix: hide spore when invisible

* feat: support the nft transferring in light client mode

* refactor: migrate NodeService to NetworksService
  • Loading branch information
homura authored Nov 15, 2023
1 parent 6360d55 commit 8abdab0
Show file tree
Hide file tree
Showing 18 changed files with 593 additions and 26 deletions.
2 changes: 2 additions & 0 deletions packages/neuron-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
"last 2 chrome versions"
],
"dependencies": {
"@ckb-lumos/base": "0.21.0-next.1",
"@ckb-lumos/codec": "0.21.0-next.1",
"@nervosnetwork/ckb-sdk-core": "0.109.0",
"@nervosnetwork/ckb-sdk-utils": "0.109.0",
"canvg": "2.0.0",
Expand Down
27 changes: 23 additions & 4 deletions packages/neuron-ui/src/components/NFTSend/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import React, { useState, useCallback, useReducer, useMemo, useRef, useEffect } from 'react'
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useState as useGlobalState, useDispatch, AppActions } from 'states'
import { AppActions, useDispatch, useState as useGlobalState } from 'states'
import { isMainnet as isMainnetUtil, isSuccessResponse, validateAddress } from 'utils'
import useGetCountDownAndFeeRateStats from 'utils/hooks/useGetCountDownAndFeeRateStats'
import TextField from 'widgets/TextField'
import Dialog from 'widgets/Dialog'
import { generateNFTSendTransaction } from 'services/remote'
import { isErrorWithI18n } from 'exceptions'
import styles from './NFTSend.module.scss'
import { NFTType } from '../SpecialAssetList'
import { generateSporeSendTransaction } from '../../services/remote/spore'

enum Fields {
Address = 'address',
Expand Down Expand Up @@ -38,7 +40,9 @@ const NFTSend = ({
onCancel,
cell,
onSuccess,
nftType = NFTType.NFT,
}: {
nftType?: NFTType
onCancel: () => void
cell: {
nftId: string
Expand Down Expand Up @@ -138,7 +142,17 @@ const NFTSend = ({
feeRate: `${suggestFeeRate}`,
}

generateNFTSendTransaction(params)
const generate = (() => {
switch (nftType) {
case NFTType.Spore: {
return generateSporeSendTransaction
}
default:
return generateNFTSendTransaction
}
})()

generate(params)
.then(res => {
if (isSuccessResponse(res)) {
globalDispatch({
Expand All @@ -156,10 +170,15 @@ const NFTSend = ({
return clearTimer
}, [isSubmittable, globalDispatch, sendState, walletId, outPoint, suggestFeeRate])

const displayNftType = (() => {
if (nftType === NFTType.Spore) return 'Spore'
return 'mNFT'
})()

return (
<Dialog
show
title={`${t('special-assets.transfer-nft')} #${nftId} mNFT`}
title={`${t('special-assets.transfer-nft')} #${nftId} ${displayNftType}`}
disabled={!isSubmittable}
onCancel={onCancel}
onConfirm={onSubmit}
Expand Down
45 changes: 39 additions & 6 deletions packages/neuron-ui/src/components/SpecialAssetList/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
nftFormatter,
PresetScript,
shannonToCKBFormatter,
sporeFormatter,
sudtValueToAmount,
toUint128Le,
useDialogWrapper,
Expand Down Expand Up @@ -193,15 +194,47 @@ export const useSpecialAssetColumnInfo = ({
const isLockedCheque = status === 'withdraw-asset' && targetTime && Date.now() < targetTime
const isNFTTransferable = assetInfo.type === NFTType.NFT && assetInfo.data === 'transferable'
const isNFTClassOrIssuer = assetInfo.type === NFTType.NFTClass || assetInfo.type === NFTType.NFTIssuer
const isSpore = assetInfo.type === NFTType.Spore

if (assetInfo.type === NFTType.NFT) {
amount = nftFormatter(type?.args)
status = 'transfer-nft'
} else if (isNFTClassOrIssuer || assetInfo.type === 'Unknown') {
amount = t('special-assets.unknown-asset')
let sporeClusterInfo: { name: string; description: string } | undefined

switch (assetInfo.type) {
case NFTType.NFT: {
amount = nftFormatter(type?.args)
status = 'transfer-nft'
break
}
case NFTType.Spore: {
if (type) {
// every spore cell is transferable
status = 'transfer-nft'
sporeClusterInfo = JSON.parse(item.customizedAssetInfo.data)
amount = sporeFormatter({ args: type.args, data: item.data, clusterName: sporeClusterInfo?.name })
}
break
}
case NFTType.NFTClass:
case NFTType.NFTIssuer:
case 'Unknown': {
amount = t('special-assets.unknown-asset')
break
}
default: {
break
}
}

return { amount, status, targetTime, isLockedCheque, isNFTTransferable, isNFTClassOrIssuer, epochsInfo }
return {
amount,
status,
targetTime,
isLockedCheque,
isNFTTransferable,
isNFTClassOrIssuer,
epochsInfo,
isSpore,
sporeClusterInfo,
}
},
[epoch, bestKnownBlockTimestamp, tokenInfoList, t]
)
Expand Down
42 changes: 38 additions & 4 deletions packages/neuron-ui/src/components/SpecialAssetList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
uniformTimeFormatter,
getExplorerUrl,
ConnectionStatus,
sporeFormatter,
} from 'utils'
import { NetworkType, HIDE_BALANCE } from 'utils/const'
import useGetCountDownAndFeeRateStats from 'utils/hooks/useGetCountDownAndFeeRateStats'
Expand All @@ -41,6 +42,7 @@ import TableNoData from 'widgets/Icons/TableNoData.png'
import { useGetAssetAccounts, useSpecialAssetColumnInfo, SpecialAssetCell } from './hooks'

import styles from './specialAssetList.module.scss'
import CopyZone from '../../widgets/CopyZone'

export interface LocktimeAssetInfo {
data: string
Expand All @@ -58,6 +60,9 @@ export enum NFTType {
NFT = 'NFT',
NFTClass = 'NFTClass',
NFTIssuer = 'NFTIssuer',

Spore = 'Spore',
Cluster = 'SporeCluster',
}

export interface NFTAssetInfo {
Expand Down Expand Up @@ -92,6 +97,7 @@ const SpecialAssetList = () => {
const [isExistAccountDialogOpen, setIsExistAccountDialogOpen] = useState<boolean>(false)
const [nFTSendCell, setNFTSendCell] = useState<
| {
nftType?: NFTType
nftId: string
outPoint: {
index: string
Expand Down Expand Up @@ -286,6 +292,16 @@ const SpecialAssetList = () => {
})
return
}
if (cell.customizedAssetInfo.type === 'Spore') {
setNFTSendCell({
// unnecessary id for the spore
nftId: cell.type?.args ?? '',
outPoint: cell.outPoint,
nftType: NFTType.Spore,
})
return
}

const handleRes =
(actionType: 'unlock' | 'withdraw-cheque' | 'claim-cheque') => (res: ControllerResponse<any>) => {
if (isSuccessResponse(res)) {
Expand Down Expand Up @@ -392,7 +408,22 @@ const SpecialAssetList = () => {
isBalance: true,
minWidth: '200px',
render(_, __, item, show) {
const { amount } = handleGetSpecialAssetColumnInfo(item)
const { amount, isSpore, sporeClusterInfo } = handleGetSpecialAssetColumnInfo(item)

if (isSpore && item.type && show) {
const formattedSporeInfo = sporeFormatter({
args: item.type.args,
data: item.data,
truncate: Infinity,
clusterName: sporeClusterInfo?.name,
})
return (
<CopyZone content={formattedSporeInfo} title={sporeClusterInfo?.description}>
{amount}
</CopyZone>
)
}

return show ? amount : HIDE_BALANCE
},
},
Expand All @@ -413,7 +444,6 @@ const SpecialAssetList = () => {
return (
<div className={styles.actionBtnBox}>
<Button
type="cancel"
label={t('special-assets.view-details')}
className={`${styles.actionBtn} ${styles.detailBtn}`}
onClick={() => onViewDetail(item)}
Expand Down Expand Up @@ -479,7 +509,6 @@ const SpecialAssetList = () => {
/>
)}
<Button
type="cancel"
label={t('special-assets.view-details')}
className={`${styles.actionBtn} ${styles.detailBtn}`}
onClick={() => onViewDetail(item)}
Expand Down Expand Up @@ -549,7 +578,12 @@ const SpecialAssetList = () => {
) : null}

{nFTSendCell ? (
<NFTSend cell={nFTSendCell} onCancel={() => setNFTSendCell(undefined)} onSuccess={handleActionSuccess} />
<NFTSend
nftType={nFTSendCell.nftType}
cell={nFTSendCell}
onCancel={() => setNFTSendCell(undefined)}
onSuccess={handleActionSuccess}
/>
) : null}

<Toast content={notice} onDismiss={() => setNotice('')} />
Expand Down
2 changes: 2 additions & 0 deletions packages/neuron-ui/src/services/remote/remoteApiWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ type Action =
| 'get-hold-sudt-cell-capacity'
| 'start-migrate'
| 'get-sync-progress-by-addresses'
// spore
| 'generate-transfer-spore-tx'

export const remoteApi =
<P = any, R = any>(action: Action) =>
Expand Down
7 changes: 7 additions & 0 deletions packages/neuron-ui/src/services/remote/spore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { remoteApi } from './remoteApiWrapper'

// eslint-disable-next-line import/prefer-default-export
export const generateSporeSendTransaction = remoteApi<
Controller.CreateNFTSendTransaction.Params,
Controller.CreateNFTSendTransaction.Response
>('generate-transfer-spore-tx')
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { sporeFormatter, truncateMiddle } from 'utils/formatters'

describe('sporeFormatter', () => {
// https://pudge.explorer.nervos.org/nft-info/0xd3b1f0634da710628a6f9faa73db028708dc79eb75de936bf39f0960cb882652/45251453428383742336079001511900429529583900220860399226448158779453103373468

const sporeArgs = '0x640b6a3dd74ff4c87f44fc459bfb1bfa3bae60d8ba593f43796383860b1b7c9c'
const sporeData =
'0xde010000100000001e000000ba0100000a000000696d6167652f6a70656798010000ffd8ffe000104a46494600010101004800480000ffdb0043000a07070807060a0808080b0a0a0b0e18100e0d0d0e1d15161118231f2524221f2221262b372f26293429212230413134393b3e3e3e252e4449433c48373d3e3bffdb0043010a0b0b0e0d0e1c10101c3b2822283b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3bffc0001108000a000a03012200021101031101ffc4001500010100000000000000000000000000000407ffc4001f1000030002020301010000000000000000010203040500110621314114ffc40014010100000000000000000000000000000000ffc40014110100000000000000000000000000000000ffda000c03010002110311003f0064773b5c2d6f9b6c2db3cc7c76ced8e1499eec7f8dd10b40a127a9a92ce9ebd9631007de50fc4ed5c9f0ed2def57ad6baf83d28ec599d8cd49249fa49fde29f4daaa62e462beb30db1f2aa6d91230529672412cc3ae99bb00f67dfa1c4c632c684e10924a5250939a28554503a0001f001f9c0ffd9200000000c800b63cb44a925e1fbce395e76ceb6f115518130ff210be32b922a93bc5d64'
const clusterId = '0x0c800b63cb44a925e1fbce395e76ceb6f115518130ff210be32b922a93bc5d64'

const truncatedSporeId = truncateMiddle(sporeArgs)
const truncatedClusterId = truncateMiddle(clusterId)

it('should truncate args', () => {
expect(truncatedSporeId).toBe('0x640b6a...0b1b7c9c')
expect(truncatedClusterId).toBe('0x0c800b...93bc5d64')
})

it('should work as expected without cluster', () => {
const formatted = sporeFormatter({ args: sporeArgs })
expect(formatted).toBe(`[${truncatedSporeId}] Spore`)
})

it('should work as expected with cluster', () => {
const withoutName = sporeFormatter({ args: sporeArgs, data: sporeData })
expect(withoutName).toBe(`[${truncatedSporeId}] [${truncatedClusterId}] Spore`)

const clusterName = 'a very long cluster name'
const withName = sporeFormatter({ args: sporeArgs, data: sporeData, clusterName })
expect(withName).toBe(`[${truncatedSporeId}] [${truncateMiddle(clusterName)}] Spore`)
})
})
48 changes: 48 additions & 0 deletions packages/neuron-ui/src/utils/formatters.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { molecule } from '@ckb-lumos/codec'
import { blockchain } from '@ckb-lumos/base'
import { TFunction } from 'i18next'
import { FailureFromController } from 'services/remote/remoteApiWrapper'
import { CapacityUnit } from './enums'
Expand Down Expand Up @@ -295,6 +297,52 @@ export const nftFormatter = (hex?: string, idOnly = false) => {
return `#${id} mNFT`
}

export function truncateMiddle(str: string, start = 8, end = start): string {
if (str.length <= start + end) {
return str
}
return `${str.slice(0, start)}...${str.slice(-end)}`
}

type FormatterOptions = { args: string; data?: string; clusterName?: string; truncate?: number }
export const sporeFormatter = ({ args, data, clusterName, truncate }: FormatterOptions) => {
let format = 'Spore'

const SporeData = molecule.table(
{
contentType: blockchain.Bytes,
content: blockchain.Bytes,
clusterId: blockchain.BytesOpt,
},
['contentType', 'content', 'clusterId']
)

if (data) {
try {
const { clusterId } = SporeData.unpack(data)

// the name may be empty when it works with the light client.
// a spore cell may appear before the cluster cell is found in the light client.
// So we need a placeholder for the name.
if (clusterId && !clusterName) {
format = `[${truncateMiddle(clusterId, truncate)}] ${format}`
}
if (clusterId && clusterName) {
format = `[${truncateMiddle(clusterName, truncate)}] ${format}`
}
} catch {
// the Spore contract seems not guarantee the data always valid
// empty catch here to avoid crash
}
}

if (args) {
format = `[${truncateMiddle(args, truncate)}] ${format}`
}

return format
}

export const errorFormatter = (error: string | FailureFromController['message'], t: TFunction) => {
// empty string should return unknown error too
const unknownError = t('messages.unknown-error')
Expand Down
13 changes: 11 additions & 2 deletions packages/neuron-ui/src/widgets/CopyZone/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,17 @@ type CopyZoneProps = React.PropsWithChildren<{
style?: React.CSSProperties
className?: string
maskRadius?: number
title?: string
}>
const CopyZone = ({ children, content, name, style, className = '', maskRadius = 16 }: CopyZoneProps) => {
const CopyZone = ({
children,
content,
name,
style,
className = '',
maskRadius = 16,
title = content,
}: CopyZoneProps) => {
const [t] = useTranslation()
const [copied, setCopied] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout>>()
Expand All @@ -36,7 +45,7 @@ const CopyZone = ({ children, content, name, style, className = '', maskRadius =
onClick={onCopy}
className={`${styles.container} ${className}`}
style={style}
title={content}
title={title}
>
{children}
<div className={styles.hoverShow} style={{ borderRadius: `${maskRadius}px` }}>
Expand Down
1 change: 1 addition & 0 deletions packages/neuron-wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@ckb-lumos/rpc": "^0.21.0-next.1",
"@iarna/toml": "2.2.5",
"@ledgerhq/hw-transport-node-hid": "6.27.16",
"@spore-sdk/core": "0.1.0-beta.9",
"archiver": "5.3.0",
"async": "3.2.4",
"bn.js": "4.12.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export default class LightConnector extends Connector<CKBComponents.Hash> {
assetAccountInfo.getNftIssuerInfo().cellDep,
assetAccountInfo.getLegacyAnyoneCanPayInfo().cellDep,
assetAccountInfo.getChequeInfo().cellDep,
...assetAccountInfo.getSporeInfos().map(info => info.cellDep),
...assetAccountInfo.getSporeClusterInfo().map(info => info.cellDep),
]
const fetchTxHashes = fetchCellDeps.map(v => v.outPoint.txHash).map<[string, string]>(v => ['fetchTransaction', v])
const txs = await this.lightRpc
Expand Down
Loading

2 comments on commit 8abdab0

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Packaging for test is done in 6876198209

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Packaging for test is done in 6876199317

Please sign in to comment.