diff --git a/package.json b/package.json index cb5e8440..b704905e 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "@types/react": "^18.0.9", "@types/react-cache": "^2.0.1", "@types/react-dom": "^18.0.4", - "@unisat/wallet-sdk": "1.1.2", + "@unisat/wallet-sdk": "1.2.1", "antd": "^4.20.4", "antd-dayjs-webpack-plugin": "1.0.6", "assert": "^2.0.0", diff --git a/src/background/controller/wallet.ts b/src/background/controller/wallet.ts index 5f2f242f..8e12dd0d 100644 --- a/src/background/controller/wallet.ts +++ b/src/background/controller/wallet.ts @@ -40,6 +40,7 @@ import { publicKeyToAddress, scriptPkToAddress } from '@unisat/wallet-sdk/lib/ad import { ECPair, bitcoin } from '@unisat/wallet-sdk/lib/bitcoin-core'; import { signMessageOfBIP322Simple } from '@unisat/wallet-sdk/lib/message'; import { toPsbtNetwork } from '@unisat/wallet-sdk/lib/network'; +import { getAddressUtxoDust } from '@unisat/wallet-sdk/lib/transaction'; import { toXOnly } from '@unisat/wallet-sdk/lib/utils'; import { ContactBookItem } from '../service/contactBook'; @@ -861,6 +862,14 @@ export class WalletController extends BaseController { return Object.assign(v, { pubkey: account.pubkey }); }); + const toDust = getAddressUtxoDust(to); + + assetUtxos.forEach((v) => { + if (v.satoshis < toDust) { + throw new Error('Unable to send inscriptions to this address in batches, please send them one by one.'); + } + }); + if (!btcUtxos) { btcUtxos = await this.getBTCUtxos(); } @@ -1223,8 +1232,8 @@ export class WalletController extends BaseController { return openapiService.getFeeSummary(); }; - inscribeBRC20Transfer = (address: string, tick: string, amount: string, feeRate: number) => { - return openapiService.inscribeBRC20Transfer(address, tick, amount, feeRate); + inscribeBRC20Transfer = (address: string, tick: string, amount: string, feeRate: number, outputValue: number) => { + return openapiService.inscribeBRC20Transfer(address, tick, amount, feeRate, outputValue); }; getInscribeResult = (orderId: string) => { @@ -1484,6 +1493,8 @@ export class WalletController extends BaseController { btcUtxos = await this.getBTCUtxos(); } + const changeDust = getAddressUtxoDust(account.address); + const _assetUtxos: UnspentOutput[] = []; let total = 0; let change = 0; @@ -1493,13 +1504,13 @@ export class WalletController extends BaseController { _assetUtxos.push(v); if (total >= amount) { change = total - amount; - if (change == 0 || change > 546) { + if (change == 0 || change >= changeDust) { break; } } } - if (change != 0 && change < 546) { - throw new Error('Can not construct change greater than 546.'); + if (change != 0 && change < changeDust) { + throw new Error('The amount for change is too low, please adjust the sending amount.'); } assetUtxos = _assetUtxos; diff --git a/src/background/service/openapi.ts b/src/background/service/openapi.ts index 51daa875..95ca1702 100644 --- a/src/background/service/openapi.ts +++ b/src/background/service/openapi.ts @@ -252,8 +252,14 @@ export class OpenApiService { return this.httpGet('/address/search', { domain }); } - async inscribeBRC20Transfer(address: string, tick: string, amount: string, feeRate: number): Promise { - return this.httpPost('/brc20/inscribe-transfer', { address, tick, amount, feeRate }); + async inscribeBRC20Transfer( + address: string, + tick: string, + amount: string, + feeRate: number, + outputValue: number + ): Promise { + return this.httpPost('/brc20/inscribe-transfer', { address, tick, amount, feeRate, outputValue }); } async getInscribeResult(orderId: string): Promise { diff --git a/src/shared/types.ts b/src/shared/types.ts index ce794d8f..ddeb2b5a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -248,6 +248,7 @@ export interface Arc20Balance { export interface TokenInfo { totalSupply: string; totalMinted: string; + decimal: number; } export enum TokenInscriptionType { diff --git a/src/ui/components/BRC20Preview/index.tsx b/src/ui/components/BRC20Preview/index.tsx index 92af035f..b8a67210 100644 --- a/src/ui/components/BRC20Preview/index.tsx +++ b/src/ui/components/BRC20Preview/index.tsx @@ -27,6 +27,16 @@ export default function BRC20Preview({ if (!balance) { balance = 'deploy'; } + let balanceSize = 'xxl'; + if (balance.length < 7) { + balanceSize = 'xxl'; + } else if (balance.length < 14) { + balanceSize = 'xl'; + } else if (balance.length < 21) { + balanceSize = 'md'; + } else { + balanceSize = 'sm'; + } return ( - + diff --git a/src/ui/components/Input/index.tsx b/src/ui/components/Input/index.tsx index 1a490022..0c8e2a66 100644 --- a/src/ui/components/Input/index.tsx +++ b/src/ui/components/Input/index.tsx @@ -36,6 +36,7 @@ export interface InputProps { onAmountInputChange?: (amount: string) => void; disabled?: boolean; disableDecimal?: boolean; + enableBrc20Decimal?: boolean; } type Presets = keyof typeof $inputPresets; @@ -89,7 +90,15 @@ function PasswordInput(props: InputProps) { } function AmountInput(props: InputProps) { - const { placeholder, onAmountInputChange, disabled, style: $inputStyleOverride, disableDecimal, ...rest } = props; + const { + placeholder, + onAmountInputChange, + disabled, + style: $inputStyleOverride, + disableDecimal, + enableBrc20Decimal, + ...rest + } = props; const $style = Object.assign({}, $baseInputStyle, $inputStyleOverride, disabled ? { color: colors.textDim } : {}); if (!onAmountInputChange) { @@ -109,9 +118,16 @@ function AmountInput(props: InputProps) { setInputValue(value); } } else { - if (/^\d*\.?\d{0,8}$/.test(value) || value === '') { - setValidAmount(value); - setInputValue(value); + if (enableBrc20Decimal) { + if (/^\d*\.?\d{0,18}$/.test(value) || value === '') { + setValidAmount(value); + setInputValue(value); + } + } else { + if (/^\d*\.?\d{0,8}$/.test(value) || value === '') { + setValidAmount(value); + setInputValue(value); + } } } }; diff --git a/src/ui/components/OutputValueBar/index.tsx b/src/ui/components/OutputValueBar/index.tsx index e72e82cb..77afcdab 100644 --- a/src/ui/components/OutputValueBar/index.tsx +++ b/src/ui/components/OutputValueBar/index.tsx @@ -2,6 +2,7 @@ import { CSSProperties, useEffect, useState } from 'react'; import { colors } from '@/ui/theme/colors'; +import { useTools } from '../ActionComponent'; import { Column } from '../Column'; import { Input } from '../Input'; import { Row } from '../Row'; @@ -12,7 +13,15 @@ enum FeeRateType { CUSTOM } -export function OutputValueBar({ defaultValue, onChange }: { defaultValue: number; onChange: (val: number) => void }) { +export function OutputValueBar({ + defaultValue, + minValue, + onChange +}: { + defaultValue: number; + minValue: number; + onChange: (val: number) => void; +}) { const options = [ { title: 'Current', @@ -24,17 +33,35 @@ export function OutputValueBar({ defaultValue, onChange }: { defaultValue: numbe ]; const [optionIndex, setOptionIndex] = useState(FeeRateType.CURRENT); const [inputVal, setInputVal] = useState(''); + const [currentValue, setCurrentValue] = useState(defaultValue); useEffect(() => { let val: any = defaultValue; if (optionIndex === FeeRateType.CUSTOM) { + if (!inputVal) { + onChange(0); + setCurrentValue(0); + return; + } val = parseInt(inputVal); } else if (options.length > 0) { val = options[optionIndex].value; } + if (val + '' != inputVal) { + setInputVal(val); + } onChange(val); + setCurrentValue(val); }, [optionIndex, inputVal]); + useEffect(() => { + if (minValue && currentValue < minValue) { + // setOptionIndex(FeeRateType.CUSTOM); + // setInputVal(minValue + ''); + } + }, [minValue, currentValue]); + + const tools = useTools(); return ( @@ -44,6 +71,10 @@ export function OutputValueBar({ defaultValue, onChange }: { defaultValue: numbe
{ + if (defaultValue < minValue && index === 0) { + tools.showTip('Can not change to a lower value'); + return; + } setOptionIndex(index); }} style={Object.assign( @@ -74,7 +105,6 @@ export function OutputValueBar({ defaultValue, onChange }: { defaultValue: numbe preset="amount" disableDecimal placeholder={'sats'} - defaultValue={inputVal} value={inputVal} onAmountInputChange={(val) => { setInputVal(val); diff --git a/src/ui/pages/Approval/components/InscribeTransfer.tsx b/src/ui/pages/Approval/components/InscribeTransfer.tsx index 1f2f70b1..f8270c1d 100644 --- a/src/ui/pages/Approval/components/InscribeTransfer.tsx +++ b/src/ui/pages/Approval/components/InscribeTransfer.tsx @@ -1,13 +1,16 @@ import { Tooltip } from 'antd'; +import BigNumber from 'bignumber.js'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { InscribeOrder, RawTxInfo, TokenBalance, TxType } from '@/shared/types'; +import { InscribeOrder, RawTxInfo, TokenBalance, TokenInfo, TxType } from '@/shared/types'; import { Button, Card, Column, Content, Footer, Header, Icon, Input, Layout, Row, Text } from '@/ui/components'; import { useTools } from '@/ui/components/ActionComponent'; import { Loading } from '@/ui/components/ActionComponent/Loading'; import { Empty } from '@/ui/components/Empty'; import { FeeRateBar } from '@/ui/components/FeeRateBar'; import InscriptionPreview from '@/ui/components/InscriptionPreview'; +import { OutputValueBar } from '@/ui/components/OutputValueBar'; +import { RBFBar } from '@/ui/components/RBFBar'; import WebsiteBar from '@/ui/components/WebsiteBar'; import { useCurrentAccount } from '@/ui/state/accounts/hooks'; import { useNetworkType } from '@/ui/state/settings/hooks'; @@ -19,6 +22,7 @@ import { import { fontSizes } from '@/ui/theme/font'; import { spacing } from '@/ui/theme/spacing'; import { satoshisToAmount, useApproval, useLocationState, useWallet } from '@/ui/utils'; +import { getAddressUtxoDust } from '@unisat/wallet-sdk/lib/transaction'; import { useNavigate } from '../../MainRoute'; import SignPsbt from './SignPsbt'; @@ -51,8 +55,9 @@ interface ContextData { tokenBalance?: TokenBalance; order?: InscribeOrder; rawTxInfo?: RawTxInfo; - amount?: number; + amount?: string; isApproval: boolean; + tokenInfo?: TokenInfo; } interface UpdateContextDataParams { @@ -62,14 +67,15 @@ interface UpdateContextDataParams { tokenBalance?: TokenBalance; order?: InscribeOrder; rawTxInfo?: RawTxInfo; - amount?: number; + amount?: string; + tokenInfo?: TokenInfo; } export default function InscribeTransfer({ params: { data, session } }: Props) { const [contextData, setContextData] = useState({ step: Step.STEP1, ticker: data.ticker, - amount: parseInt(data.amount), + amount: data.amount, session, isApproval: true }); @@ -149,6 +155,13 @@ function InscribeTransferStep({ contextData, updateContextData }: StepProps) { const [disabled, setDisabled] = useState(true); const [inputDisabled, setInputDisabled] = useState(false); + + const defaultOutputValue = getAddressUtxoDust(account.address); + + const [outputValue, setOutputValue] = useState(defaultOutputValue); + + const [enableRBF, setEnableRBF] = useState(false); + useEffect(() => { if (contextData.amount) { setInputAmount(contextData.amount.toString()); @@ -159,8 +172,15 @@ function InscribeTransferStep({ contextData, updateContextData }: StepProps) { useEffect(() => { setInputError(''); setDisabled(true); - - const amount = parseInt(inputAmount); + if (inputAmount.split('.').length > 1) { + const decimal = inputAmount.split('.')[1].length; + const token_decimal = contextData.tokenInfo?.decimal || 0; + if (decimal > token_decimal) { + setInputError(`This token only supports up to ${token_decimal} decimal places.`); + return; + } + } + const amount = new BigNumber(inputAmount); if (!amount) { return; } @@ -169,11 +189,11 @@ function InscribeTransferStep({ contextData, updateContextData }: StepProps) { return; } - if (amount <= 0) { + if (amount.lte(0)) { return; } - if (amount > parseInt(contextData.tokenBalance.availableBalanceSafe)) { + if (amount.gt(contextData.tokenBalance.availableBalanceSafe)) { setInputError('Insufficient Balance'); return; } @@ -182,15 +202,24 @@ function InscribeTransferStep({ contextData, updateContextData }: StepProps) { return; } + if (outputValue < defaultOutputValue) { + setInputError(`OutputValue must be at least ${defaultOutputValue}`); + return; + } + + if (!outputValue) { + return; + } + setDisabled(false); - }, [inputAmount, feeRate, contextData.tokenBalance]); + }, [inputAmount, feeRate, outputValue, contextData.tokenBalance]); useEffect(() => { fetchUtxos(); wallet .getBRC20Summary(account.address, contextData.ticker) .then((v) => { - updateContextData({ tokenBalance: v.tokenBalance }); + updateContextData({ tokenBalance: v.tokenBalance, tokenInfo: v.tokenInfo }); }) .catch((e) => { tools.toastError(e.message); @@ -200,13 +229,19 @@ function InscribeTransferStep({ contextData, updateContextData }: StepProps) { const onClickInscribe = async () => { try { tools.showLoading(true); - const amount = parseInt(inputAmount); - const order = await wallet.inscribeBRC20Transfer(account.address, contextData.ticker, amount.toString(), feeRate); + const amount = inputAmount; + const order = await wallet.inscribeBRC20Transfer( + account.address, + contextData.ticker, + amount, + feeRate, + outputValue + ); const rawTxInfo = await prepareSendBTC({ toAddressInfo: { address: order.payAddress, domain: '' }, toAmount: order.totalFee, feeRate: feeRate, - enableRBF: true + enableRBF }); updateContextData({ order, amount, rawTxInfo, step: Step.STEP2 }); } catch (e) { @@ -294,6 +329,7 @@ function InscribeTransferStep({ contextData, updateContextData }: StepProps) { placeholder={'Amount'} value={inputAmount} autoFocus={true} + enableBrc20Decimal={true} onAmountInputChange={(amount) => { setInputAmount(amount); }} @@ -302,6 +338,18 @@ function InscribeTransferStep({ contextData, updateContextData }: StepProps) { {inputError && } + + + + { + setOutputValue(val); + }} + /> + + + + + { + setEnableRBF(val); + }} + /> + @@ -368,7 +424,14 @@ function InscribeConfirmStep({ contextData, updateContextData }: StepProps) { - + @@ -376,6 +439,7 @@ function InscribeConfirmStep({ contextData, updateContextData }: StepProps) { @@ -567,7 +631,8 @@ function InscribeResultStep({ navigate('BRC20SendScreen', { tokenBalance: v.tokenBalance, selectedInscriptionIds: [result.inscriptionId], - selectedAmount: parseInt(result.amount) + selectedAmount: result.amount, + tokenInfo: v.tokenInfo }); } }) diff --git a/src/ui/pages/BRC20/BRC20SendScreen.tsx b/src/ui/pages/BRC20/BRC20SendScreen.tsx index 166fcf68..ab2b0166 100644 --- a/src/ui/pages/BRC20/BRC20SendScreen.tsx +++ b/src/ui/pages/BRC20/BRC20SendScreen.tsx @@ -1,23 +1,27 @@ import { Checkbox } from 'antd'; +import BigNumber from 'bignumber.js'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useLocation } from 'react-router-dom'; -import { RawTxInfo, TokenBalance, TokenTransfer, TxType } from '@/shared/types'; +import { RawTxInfo, TokenBalance, TokenInfo, TokenTransfer, TxType } from '@/shared/types'; import { Button, Column, Content, Header, Input, Layout, Row, Text } from '@/ui/components'; import { useTools } from '@/ui/components/ActionComponent'; import BRC20Preview from '@/ui/components/BRC20Preview'; import { FeeRateBar } from '@/ui/components/FeeRateBar'; +import { RBFBar } from '@/ui/components/RBFBar'; import { RefreshButton } from '@/ui/components/RefreshButton'; import { TabBar } from '@/ui/components/TabBar'; import { useCurrentAccount } from '@/ui/state/accounts/hooks'; import { useFetchUtxosCallback, + usePrepareSendOrdinalsInscriptionCallback, usePrepareSendOrdinalsInscriptionsCallback, usePushOrdinalsTxCallback } from '@/ui/state/transactions/hooks'; import { colors } from '@/ui/theme/colors'; import { fontSizes } from '@/ui/theme/font'; import { useWallet } from '@/ui/utils'; +import { getAddressUtxoDust } from '@unisat/wallet-sdk/lib/transaction'; import { SignPsbt } from '../Approval/components'; import { useNavigate } from '../MainRoute'; @@ -37,7 +41,7 @@ function Step1({ useEffect(() => { setDisabled(true); - if (contextData.transferAmount <= 0) { + if (new BigNumber(contextData.transferAmount).lte(0)) { return; } @@ -165,7 +169,7 @@ function TransferableList({ useEffect(() => { fetchData(); }, [pagination]); - const totalAmount = items.reduce((pre, cur) => pre + parseInt(cur.amount), 0); + const totalAmount = items.reduce((pre, cur) => new BigNumber(cur.amount).plus(pre), new BigNumber(0)).toString(); const selectedCount = useMemo(() => contextData.inscriptionIdSet.size, [contextData]); @@ -196,10 +200,10 @@ function TransferableList({ if (contextData.inscriptionIdSet.has(v.inscriptionId)) { const inscriptionIdSet = new Set(contextData.inscriptionIdSet); inscriptionIdSet.delete(v.inscriptionId); - const transferAmount = contextData.transferAmount - parseInt(v.amount); + const transferAmount = new BigNumber(contextData.transferAmount).minus(new BigNumber(v.amount)); updateContextData({ inscriptionIdSet, - transferAmount + transferAmount: transferAmount.toString() }); if (allSelected) { setAllSelected(false); @@ -207,7 +211,9 @@ function TransferableList({ } else { const inscriptionIdSet = new Set(contextData.inscriptionIdSet); inscriptionIdSet.add(v.inscriptionId); - const transferAmount = contextData.transferAmount + parseInt(v.amount); + const transferAmount = new BigNumber(contextData.transferAmount) + .plus(new BigNumber(v.amount)) + .toString(); updateContextData({ inscriptionIdSet, transferAmount @@ -243,7 +249,7 @@ function TransferableList({ } else { updateContextData({ inscriptionIdSet: new Set(), - transferAmount: 0 + transferAmount: '0' }); } }} @@ -296,9 +302,11 @@ function Step2({ }, []); const prepareSendOrdinalsInscriptions = usePrepareSendOrdinalsInscriptionsCallback(); + const prepareSendOrdinalsInscription = usePrepareSendOrdinalsInscriptionCallback(); const [disabled, setDisabled] = useState(true); + const [enableRBF, setEnableRBF] = useState(false); useEffect(() => { setDisabled(true); if (!contextData.receiver) { @@ -312,13 +320,25 @@ function Step2({ try { tools.showLoading(true); const inscriptionIds = Array.from(contextData.inscriptionIdSet); - const rawTxInfo = await prepareSendOrdinalsInscriptions({ - toAddressInfo: { address: contextData.receiver, domain: '' }, - inscriptionIds, - feeRate: contextData.feeRate, - enableRBF: false - }); - navigate('SignOrdinalsTransactionScreen', { rawTxInfo }); + if (inscriptionIds.length === 1) { + const rawTxInfo = await prepareSendOrdinalsInscription({ + toAddressInfo: { address: contextData.receiver, domain: '' }, + inscriptionId: inscriptionIds[0], + feeRate: contextData.feeRate, + outputValue: getAddressUtxoDust(contextData.receiver), + enableRBF + }); + navigate('SignOrdinalsTransactionScreen', { rawTxInfo }); + } else { + const rawTxInfo = await prepareSendOrdinalsInscriptions({ + toAddressInfo: { address: contextData.receiver, domain: '' }, + inscriptionIds, + feeRate: contextData.feeRate, + enableRBF + }); + navigate('SignOrdinalsTransactionScreen', { rawTxInfo }); + } + // updateContextData({ tabKey: TabKey.STEP3, rawTxInfo: txInfo }); } catch (e) { const error = e as Error; @@ -358,6 +378,14 @@ function Step2({ }} /> + + + { + setEnableRBF(val); + }} + /> +