From 57b018e5d0284312bccec4fd0ba1fa65200a1082 Mon Sep 17 00:00:00 2001 From: devchenyan Date: Thu, 14 Mar 2024 14:25:36 +0800 Subject: [PATCH] Support "amend a pending transaction" (#3045) Co-authored-by: Chen Yu --- .../AmendSUDTSend/amendSUDTSend.module.scss | 160 ++++++++++ .../src/components/AmendSUDTSend/hooks.ts | 147 +++++++++ .../src/components/AmendSUDTSend/index.tsx | 288 ++++++++++++++++++ .../AmendSend/amendSend.module.scss | 201 ++++++++++++ .../src/components/AmendSend/hooks.ts | 154 ++++++++++ .../src/components/AmendSend/index.tsx | 268 ++++++++++++++++ .../src/components/History/RowExtend.tsx | 88 ++++-- .../components/History/history.module.scss | 18 ++ .../neuron-ui/src/components/History/hooks.ts | 19 +- .../src/components/PasswordRequest/hooks.ts | 7 +- packages/neuron-ui/src/locales/en.json | 5 +- packages/neuron-ui/src/locales/es.json | 199 ++++++------ packages/neuron-ui/src/locales/fr.json | 5 +- packages/neuron-ui/src/locales/zh-tw.json | 5 +- packages/neuron-ui/src/locales/zh.json | 5 +- packages/neuron-ui/src/router.tsx | 22 ++ .../neuron-ui/src/services/remote/offline.ts | 1 + .../src/services/remote/remoteApiWrapper.ts | 1 + packages/neuron-ui/src/types/App/index.d.ts | 1 + .../neuron-ui/src/types/Controller/index.d.ts | 2 + .../block-sync-renderer/tx-status-listener.ts | 2 + .../src/controllers/anyone-can-pay.ts | 4 +- packages/neuron-wallet/src/controllers/api.ts | 5 + .../src/controllers/transactions.ts | 9 + .../neuron-wallet/src/controllers/wallets.ts | 4 +- .../chain/entities/amend-transaction.ts | 31 ++ .../neuron-wallet/src/database/chain/index.ts | 11 +- .../1709008125088-AmendTransaction.ts | 13 + .../src/database/chain/ormconfig.ts | 4 + .../src/services/amend-transaction.ts | 21 ++ .../src/services/transaction-sender.ts | 11 +- .../src/services/tx/failed-transaction.ts | 63 +++- 32 files changed, 1637 insertions(+), 137 deletions(-) create mode 100644 packages/neuron-ui/src/components/AmendSUDTSend/amendSUDTSend.module.scss create mode 100644 packages/neuron-ui/src/components/AmendSUDTSend/hooks.ts create mode 100644 packages/neuron-ui/src/components/AmendSUDTSend/index.tsx create mode 100644 packages/neuron-ui/src/components/AmendSend/amendSend.module.scss create mode 100644 packages/neuron-ui/src/components/AmendSend/hooks.ts create mode 100644 packages/neuron-ui/src/components/AmendSend/index.tsx create mode 100644 packages/neuron-wallet/src/database/chain/entities/amend-transaction.ts create mode 100644 packages/neuron-wallet/src/database/chain/migrations/1709008125088-AmendTransaction.ts create mode 100644 packages/neuron-wallet/src/services/amend-transaction.ts diff --git a/packages/neuron-ui/src/components/AmendSUDTSend/amendSUDTSend.module.scss b/packages/neuron-ui/src/components/AmendSUDTSend/amendSUDTSend.module.scss new file mode 100644 index 0000000000..2ef4fcb512 --- /dev/null +++ b/packages/neuron-ui/src/components/AmendSUDTSend/amendSUDTSend.module.scss @@ -0,0 +1,160 @@ +@import '../../styles/mixin.scss'; +$bottomHeight: 24px; +$headerHeight: 72px; +$noticeHeight: 60px; + +.layout { + display: flex; + + .left { + flex: 1; + position: relative; + + .addressCell { + margin-bottom: 10px; + .label { + font-size: 14px; + line-height: 20px; + color: var(--secondary-text-color); + margin-bottom: 8px; + } + .content { + font-family: 'JetBrains Mono'; + display: flex; + align-items: center; + color: var(--main-text-color); + border: 1px solid var(--divide-line-color); + background-color: var(--input-disabled-color); + border-radius: 8px; + padding: 10px 16px; + word-break: break-word; + white-space: break-spaces; + } + } + + .inputCell { + background: var(--secondary-background-color); + padding: 20px 16px 18px; + border-radius: 16px; + margin-bottom: 16px; + .addresstField { + font-family: 'JetBrains Mono'; + } + .textFieldClass { + margin-bottom: 10px; + } + } + + .leftFooter { + position: absolute; + width: 100%; + bottom: 0; + left: 0; + height: 80px; + display: flex; + justify-content: center; + align-items: center; + + .addButton { + min-width: 216px; + padding: 20px; + box-shadow: 0 0 10px var(--primary-color); + display: flex; + align-items: center; + justify-content: center; + &_icon { + margin-right: 8px; + } + svg { + g, + path { + fill: var(--primary-text-color); + } + } + } + } + } + + &.withFullySynced { + .right { + height: calc(100vh - $headerHeight - $bottomHeight - $noticeHeight); + } + } + + .right { + margin-left: 16px; + flex: 1; + position: relative; + border-radius: 16px; + background: var(--secondary-background-color); + height: calc(100vh - $headerHeight - $bottomHeight); + $actionHeight: 108px; + + .content { + $contentPadding: 16px; + padding: 16px; + /** + These styles are for showing the full tips, because of content needs to scroll on the y-axis. + Then the overflow property that is set as scroll will truncate the tips overflow of the content. + So I need to expand the content's left to show the full tips. + */ + position: relative; + left: -48px; + padding-left: 64px; + width: calc(100% - 32px); + overflow-y: scroll; + height: calc(100% - $actionHeight - 2 * $contentPadding); + } + + .rightFooter { + position: absolute; + width: 100%; + bottom: 16px; + left: 0; + text-align: center; + .actions { + display: flex; + justify-content: center; + + button { + width: 216px; + &:last-child { + margin-left: 12px; + } + } + } + } + } +} + +.headerContainer { + display: flex; + align-items: center; + + .goBack { + margin-right: 8px; + cursor: pointer; + g, + path { + stroke: var(--main-text-color); + } + } + .btn { + margin-left: 12px; + min-width: 30px; + height: 16px; + padding: 0 8px; + border-radius: 0; + border-left: 1px solid var(--button-cancel-color); + svg { + g, + path { + fill: var(--main-text-color); + } + } + } + + .balance { + font-size: 14px; + } +} diff --git a/packages/neuron-ui/src/components/AmendSUDTSend/hooks.ts b/packages/neuron-ui/src/components/AmendSUDTSend/hooks.ts new file mode 100644 index 0000000000..3b17fe22a6 --- /dev/null +++ b/packages/neuron-ui/src/components/AmendSUDTSend/hooks.ts @@ -0,0 +1,147 @@ +import React, { useState, useCallback, useEffect, useMemo } from 'react' +import { TFunction } from 'i18next' +import { AppActions } from 'states/stateProvider/reducer' +import { getTransaction as getOnChainTransaction } from 'services/chain' +import { getTransaction as getSentTransaction, getTransactionSize, getTransactionList } from 'services/remote' +import { isSuccessResponse } from 'utils' + +export const useInitialize = ({ + hash, + walletID, + dispatch, +}: { + hash: string + walletID: string + isMainnet: boolean + dispatch: React.Dispatch + t: TFunction +}) => { + const [transaction, setTransaction] = useState(null) + const [size, setSize] = useState(0) + const [minPrice, setMinPrice] = useState('0') + const [price, setPrice] = useState('0') + const [description, setDescription] = useState('') + const [showConfirmedAlert, setShowConfirmedAlert] = useState(false) + const [sudtInfo, setSudtInfo] = useState(null) + const [txValue, setTxValue] = useState('0') + + const onDescriptionChange = useCallback( + (e: React.SyntheticEvent) => { + const { value } = e.target as HTMLInputElement + setDescription(value) + }, + [dispatch] + ) + + const fee = useMemo(() => { + const ratio = BigInt(1000) + const base = BigInt(size) * BigInt(price) + const curFee = base / ratio + if (curFee * ratio < base) { + return curFee + BigInt(1) + } + return curFee + }, [price, size]) + + const fetchInitData = useCallback(async () => { + const { + // @ts-expect-error Replace-By-Fee (RBF) + min_replace_fee: minFee, + transaction: { outputsData }, + } = await getOnChainTransaction(hash) + if (!minFee) { + setShowConfirmedAlert(true) + } + + const listRes = await getTransactionList({ + walletID, + pageNo: 1, + pageSize: 10, + keywords: hash, + }) + if (isSuccessResponse(listRes)) { + const list = listRes.result.items + if (list.length) { + const { sudtInfo: info, value } = list[0] + setSudtInfo(info) + setTxValue(value) + } + } + + const txRes = await getSentTransaction({ hash, walletID }) + if (isSuccessResponse(txRes)) { + const tx = txRes.result + + setTransaction({ ...tx, outputsData }) + + const sizeRes = await getTransactionSize(tx) + + if (isSuccessResponse(sizeRes) && typeof sizeRes.result === 'number') { + setSize(sizeRes.result) + if (minFee) { + const mPrice = ((BigInt(minFee) * BigInt(1000)) / BigInt(sizeRes.result)).toString() + setMinPrice(mPrice) + setPrice(mPrice) + } + } + } + }, [hash, setShowConfirmedAlert, setPrice, setTransaction, setSize, setMinPrice]) + + useEffect(() => { + fetchInitData() + }, []) + + const onSubmit = useCallback( + async (e: React.FormEvent) => { + const { + dataset: { walletId, status }, + } = e.target as HTMLFormElement + e.preventDefault() + if (status !== 'ready' || !transaction) { + return + } + try { + // @ts-expect-error Replace-By-Fee (RBF) + const { min_replace_fee: minFee } = await getOnChainTransaction(hash) + if (!minFee) { + setShowConfirmedAlert(true) + return + } + + const actionType = + transaction.inputs.length > transaction.witnesses.length ? 'send-sudt' : 'send-acp-sudt-to-new-cell' + + dispatch({ + type: AppActions.RequestPassword, + payload: { + walletID: walletId as string, + amendHash: hash, + actionType, + }, + }) + } catch { + // ignore + } + }, + [dispatch, walletID, hash, setShowConfirmedAlert, transaction] + ) + + return { + setPrice, + price, + description, + onDescriptionChange, + fee, + transaction, + setTransaction, + minPrice, + showConfirmedAlert, + onSubmit, + sudtInfo, + txValue, + } +} + +export default { + useInitialize, +} diff --git a/packages/neuron-ui/src/components/AmendSUDTSend/index.tsx b/packages/neuron-ui/src/components/AmendSUDTSend/index.tsx new file mode 100644 index 0000000000..9ed69091e4 --- /dev/null +++ b/packages/neuron-ui/src/components/AmendSUDTSend/index.tsx @@ -0,0 +1,288 @@ +import React, { useEffect, useMemo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useParams } from 'react-router-dom' +import { useState as useGlobalState, useDispatch, AppActions } from 'states' +import TextField from 'widgets/TextField' +import PageContainer from 'components/PageContainer' +import Button from 'widgets/Button' +import Spinner from 'widgets/Spinner' +import { GoBack } from 'widgets/Icons/icon' +import { scriptToAddress } from '@nervosnetwork/ckb-sdk-utils' +import { + isMainnet as isMainnetUtil, + localNumberFormatter, + useGoBack, + shannonToCKBFormatter, + sudtValueToAmount, + sUDTAmountFormatter, +} from 'utils' +import { DEFAULT_SUDT_FIELDS } from 'utils/const' +import AlertDialog from 'widgets/AlertDialog' +import styles from './amendSUDTSend.module.scss' +import { useInitialize } from './hooks' + +const AmendSUDTSend = () => { + const { + app: { + loadings: { sending = false }, + showWaitForFullySynced, + }, + wallet: { id: walletID = '', addresses }, + chain: { networkID }, + experimental, + settings: { networks = [] }, + sUDTAccounts, + } = useGlobalState() + const dispatch = useDispatch() + const { t } = useTranslation() + const { hash = '' } = useParams() + + const onBack = useGoBack() + + const isMainnet = isMainnetUtil(networks, networkID) + + const { + fee, + price, + setPrice, + transaction, + onSubmit, + minPrice, + showConfirmedAlert, + sudtInfo, + description, + onDescriptionChange, + txValue, + } = useInitialize({ + hash, + walletID, + isMainnet, + dispatch, + t, + }) + + const priceError = useMemo(() => { + return Number(price || '0') < Number(minPrice) ? t('price-switch.errorTip', { minPrice }) : null + }, [price, minPrice]) + + const inputHint = t('price-switch.hintTip', { suggestFeeRate: minPrice }) + + const handlePriceChange = useCallback( + (e: React.SyntheticEvent) => { + const { value: inputValue } = e.currentTarget + + const value = inputValue.split('.')[0].replace(/[^\d]/, '') + setPrice(value) + }, + [setPrice] + ) + + const toAddress = useMemo(() => { + if (!transaction?.outputs) return '' + + const list = sUDTAccounts.map(item => item.address) + + const to = transaction?.outputs.find(output => { + const address = scriptToAddress(output.lock, isMainnet) + if (list.includes(address) || (sudtInfo && !output.type)) { + return false + } + return true + }) + if (to) { + return scriptToAddress(to.lock, isMainnet) + } + return scriptToAddress(transaction?.outputs[0].lock, isMainnet) + }, [transaction?.outputs]) + + const getLastOutputAddress = (outputs: State.DetailedOutput[]) => { + const change = outputs.find(output => { + const address = scriptToAddress(output.lock, isMainnet) + + return !!addresses.find(item => item.address === address && item.type === 1) + }) + if (change) { + return scriptToAddress(change.lock, isMainnet) + } + + const receive = outputs.find(output => { + const address = scriptToAddress(output.lock, isMainnet) + return !!addresses.find(item => item.address === address && item.type === 0) + }) + if (receive) { + return scriptToAddress(receive.lock, isMainnet) + } + + const sudt = outputs.find(output => { + const address = scriptToAddress(output.lock, isMainnet) + return !!sUDTAccounts.find(item => item.address === address) + }) + if (sudt) { + return scriptToAddress(sudt.lock, isMainnet) + } + return '' + } + + const items: { + address: string + amount: string + capacity: string + isLastOutput: boolean + output: State.DetailedOutput + }[] = useMemo(() => { + if (transaction && transaction.outputs.length) { + const lastOutputAddress = getLastOutputAddress(transaction.outputs) + return transaction.outputs.map(output => { + const address = scriptToAddress(output.lock, isMainnet) + return { + capacity: output.capacity, + address, + output, + amount: shannonToCKBFormatter(output.capacity || '0'), + isLastOutput: address === lastOutputAddress, + } + }) + } + return [] + }, [transaction?.outputs]) + + const outputsCapacity = useMemo(() => { + const outputList = items.filter(item => !item.isLastOutput) + return outputList.reduce((total, cur) => { + if (Number.isNaN(+(cur.capacity || ''))) { + return total + } + return total + BigInt(cur.capacity || '0') + }, BigInt(0)) + }, [items]) + + const lastOutputsCapacity = useMemo(() => { + if (transaction) { + const inputsCapacity = transaction.inputs.reduce((total, cur) => { + if (Number.isNaN(+(cur.capacity || ''))) { + return total + } + return total + BigInt(cur.capacity || '0') + }, BigInt(0)) + + return inputsCapacity - outputsCapacity - fee + } + return -1 + }, [transaction, fee, outputsCapacity]) + + useEffect(() => { + if (transaction) { + const outputs = items.map(item => { + const capacity = item.isLastOutput ? lastOutputsCapacity.toString() : item.capacity + return { + ...item.output, + capacity, + } + }) + dispatch({ + type: AppActions.UpdateExperimentalParams, + payload: { + tx: { + ...transaction, + description: experimental?.params?.description || description || '', + outputs, + }, + }, + }) + } + }, [lastOutputsCapacity, transaction, items, dispatch, experimental?.params?.description, description]) + + const disabled = sending || !experimental?.tx || priceError || lastOutputsCapacity < 0 + + return ( + + +

{t('navbar.send')}

+ + } + > +
+
+
+
+
+
+
{t('send.address')}
+
{toAddress}
+
+ + +
+
+
+ +
+
+ + + +
+
+
+ +
+
+
+
+
+ +
+ ) +} + +AmendSUDTSend.displayName = 'AmendSUDTSend' + +export default AmendSUDTSend diff --git a/packages/neuron-ui/src/components/AmendSend/amendSend.module.scss b/packages/neuron-ui/src/components/AmendSend/amendSend.module.scss new file mode 100644 index 0000000000..58bd47f31f --- /dev/null +++ b/packages/neuron-ui/src/components/AmendSend/amendSend.module.scss @@ -0,0 +1,201 @@ +@import '../../styles/mixin.scss'; +$bottomHeight: 24px; +$headerHeight: 72px; +$noticeHeight: 60px; + +.layout { + display: flex; + + .left { + flex: 1; + position: relative; + + .addressCell { + margin-bottom: 10px; + .label { + font-size: 14px; + line-height: 20px; + color: var(--secondary-text-color); + margin-bottom: 8px; + } + .content { + font-family: 'JetBrains Mono'; + display: flex; + align-items: center; + color: var(--main-text-color); + border: 1px solid var(--divide-line-color); + background-color: var(--input-disabled-color); + border-radius: 8px; + padding: 10px 16px; + word-break: break-word; + white-space: break-spaces; + } + } + + .inputCell { + background: var(--secondary-background-color); + padding: 20px 16px 18px; + border-radius: 16px; + margin-bottom: 16px; + .addresstField { + font-family: 'JetBrains Mono'; + } + .textFieldClass { + margin-bottom: 10px; + } + } + + .leftFooter { + position: absolute; + width: 100%; + bottom: 0; + left: 0; + height: 80px; + display: flex; + justify-content: center; + align-items: center; + + .addButton { + min-width: 216px; + padding: 20px; + box-shadow: 0 0 10px var(--primary-color); + display: flex; + align-items: center; + justify-content: center; + &_icon { + margin-right: 8px; + } + svg { + g, + path { + fill: var(--primary-text-color); + } + } + } + } + } + + &.withFullySynced { + .right { + height: calc(100vh - $headerHeight - $bottomHeight - $noticeHeight); + } + } + + .right { + margin-left: 16px; + flex: 1; + position: relative; + border-radius: 16px; + background: var(--secondary-background-color); + height: calc(100vh - $headerHeight - $bottomHeight); + $actionHeight: 108px; + + .content { + $contentPadding: 16px; + padding: 16px; + /** + These styles are for showing the full tips, because of content needs to scroll on the y-axis. + Then the overflow property that is set as scroll will truncate the tips overflow of the content. + So I need to expand the content's left to show the full tips. + */ + position: relative; + left: -48px; + padding-left: 64px; + width: calc(100% - 32px); + overflow-y: scroll; + height: calc(100% - $actionHeight - 2 * $contentPadding); + } + + .totalAmountField { + padding-bottom: 34px; + p { + margin: 0; + } + .title { + color: var(--secondary-text-color); + font-size: 14px; + } + .value { + margin-top: 22px; + color: var(--main-text-color); + font-size: 32px; + font-weight: 500; + letter-spacing: 0.5px; + font-family: 'D-DIN-PRO'; + } + .errorMessage { + margin-top: 8px; + color: var(--error-color); + } + } + + .rightFooter { + position: absolute; + width: 100%; + bottom: 16px; + left: 0; + text-align: center; + .actions { + display: flex; + justify-content: center; + + button { + width: 216px; + &:last-child { + margin-left: 12px; + } + } + } + .allowUseSent { + display: inline-block; + margin-bottom: 16px; + color: var(--secondary-text-color); + input[type='checkbox'] { + display: none; + margin-right: 8px; + } + input[type='checkbox'] + span { + display: inline-block; + padding-left: 26px; + background: url('../../widgets/Icons/Radio.svg') no-repeat left top; + user-select: none; + } + input[type='checkbox']:checked + span { + background: url('../../widgets/Icons/RadioSelected.svg') no-repeat left top; + } + } + } + } +} + +.headerContainer { + display: flex; + align-items: center; + + .goBack { + margin-right: 8px; + cursor: pointer; + g, + path { + stroke: var(--main-text-color); + } + } + .btn { + margin-left: 12px; + min-width: 30px; + height: 16px; + padding: 0 8px; + border-radius: 0; + border-left: 1px solid var(--button-cancel-color); + svg { + g, + path { + fill: var(--main-text-color); + } + } + } + + .balance { + font-size: 14px; + } +} diff --git a/packages/neuron-ui/src/components/AmendSend/hooks.ts b/packages/neuron-ui/src/components/AmendSend/hooks.ts new file mode 100644 index 0000000000..e22e716097 --- /dev/null +++ b/packages/neuron-ui/src/components/AmendSend/hooks.ts @@ -0,0 +1,154 @@ +import React, { useState, useCallback, useEffect, useMemo } from 'react' +import { TFunction } from 'i18next' +import { AppActions, StateAction, StateDispatch } from 'states/stateProvider/reducer' +import { getTransaction as getOnChainTransaction } from 'services/chain' +import { getTransaction as getSentTransaction, getTransactionSize } from 'services/remote' +import { isSuccessResponse } from 'utils' + +const clear = (dispatch: StateDispatch) => { + dispatch({ + type: AppActions.ClearSendState, + }) +} + +const useUpdateTransactionPrice = (dispatch: StateDispatch) => + useCallback( + (value: string) => { + const price = value.split('.')[0].replace(/[^\d]/, '') + dispatch({ + type: AppActions.UpdateSendPrice, + payload: price, + }) + }, + [dispatch] + ) + +const useSendDescriptionChange = (dispatch: StateDispatch) => + useCallback( + (e: React.SyntheticEvent) => { + const { value } = e.target as HTMLInputElement + dispatch({ + type: AppActions.UpdateSendDescription, + payload: value, + }) + }, + [dispatch] + ) + +export const useInitialize = ({ + hash, + walletID, + price, + dispatch, +}: { + hash: string + walletID: string + price: string + isMainnet: boolean + dispatch: React.Dispatch + t: TFunction +}) => { + const [transaction, setTransaction] = useState(null) + const [size, setSize] = useState(0) + const [minPrice, setMinPrice] = useState('0') + const [showConfirmedAlert, setShowConfirmedAlert] = useState(false) + + const updateTransactionPrice = useUpdateTransactionPrice(dispatch) + const onDescriptionChange = useSendDescriptionChange(dispatch) + + const fee = useMemo(() => { + const ratio = BigInt(1000) + const base = BigInt(size) * BigInt(price) + const curFee = base / ratio + if (curFee * ratio < base) { + return curFee + BigInt(1) + } + return curFee + }, [price, size]) + + const fetchInitData = useCallback(async () => { + const res = await getOnChainTransaction(hash) + const { + // @ts-expect-error Replace-By-Fee (RBF) + min_replace_fee: minFee, + transaction: { outputsData }, + } = res + if (!minFee) { + setShowConfirmedAlert(true) + } + + const txRes = await getSentTransaction({ hash, walletID }) + if (isSuccessResponse(txRes)) { + const tx = txRes.result + setTransaction({ + ...tx, + outputsData, + }) + + const sizeRes = await getTransactionSize(tx) + + if (isSuccessResponse(sizeRes) && typeof sizeRes.result === 'number') { + setSize(sizeRes.result) + if (minFee) { + const mPrice = ((BigInt(minFee) * BigInt(1000)) / BigInt(sizeRes.result)).toString() + setMinPrice(mPrice) + updateTransactionPrice(mPrice) + } + } + } + }, [hash, setShowConfirmedAlert, updateTransactionPrice, setTransaction, setSize, setMinPrice]) + + useEffect(() => { + fetchInitData() + }, []) + + useEffect(() => { + clear(dispatch) + }, [walletID, dispatch]) + + const onSubmit = useCallback( + async (e: React.FormEvent) => { + const { + dataset: { walletId, status }, + } = e.target as HTMLFormElement + e.preventDefault() + if (status !== 'ready') { + return + } + try { + // @ts-expect-error Replace-By-Fee (RBF) + const { min_replace_fee: minFee } = await getOnChainTransaction(hash) + if (!minFee) { + setShowConfirmedAlert(true) + return + } + dispatch({ + type: AppActions.RequestPassword, + payload: { + walletID: walletId as string, + amendHash: hash, + actionType: 'send', + }, + }) + } catch { + // ignore + } + }, + [dispatch, walletID, hash, setShowConfirmedAlert] + ) + + return { + updateTransactionPrice, + onDescriptionChange, + fee, + transaction, + setTransaction, + minPrice, + showConfirmedAlert, + onSubmit, + } +} + +export default { + useInitialize, +} diff --git a/packages/neuron-ui/src/components/AmendSend/index.tsx b/packages/neuron-ui/src/components/AmendSend/index.tsx new file mode 100644 index 0000000000..543ff7c464 --- /dev/null +++ b/packages/neuron-ui/src/components/AmendSend/index.tsx @@ -0,0 +1,268 @@ +import React, { useEffect, useMemo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useParams, useNavigate } from 'react-router-dom' +import { useState as useGlobalState, useDispatch, appState, AppActions } from 'states' +import TextField from 'widgets/TextField' +import PageContainer from 'components/PageContainer' +import Button from 'widgets/Button' +import Spinner from 'widgets/Spinner' +import { GoBack } from 'widgets/Icons/icon' +import { MIN_AMOUNT } from 'utils/const' +import { scriptToAddress } from '@nervosnetwork/ckb-sdk-utils' +import { + isMainnet as isMainnetUtil, + localNumberFormatter, + useGoBack, + shannonToCKBFormatter, + RoutePath, + isSecp256k1Address, +} from 'utils' +import AlertDialog from 'widgets/AlertDialog' +import styles from './amendSend.module.scss' +import { useInitialize } from './hooks' + +const AmendSend = () => { + const { + app: { + send = appState.send, + loadings: { sending = false }, + showWaitForFullySynced, + }, + wallet: { id: walletID = '', addresses }, + chain: { networkID }, + settings: { networks = [] }, + } = useGlobalState() + const dispatch = useDispatch() + const navigate = useNavigate() + const { t } = useTranslation() + const { hash = '' } = useParams() + + const onBack = useGoBack() + + const isMainnet = isMainnetUtil(networks, networkID) + + const { fee, updateTransactionPrice, onDescriptionChange, transaction, onSubmit, minPrice, showConfirmedAlert } = + useInitialize({ + hash, + walletID, + price: send.price, + isMainnet, + dispatch, + t, + }) + + const priceError = useMemo(() => { + return Number(send.price) < Number(minPrice) ? t('price-switch.errorTip', { minPrice }) : null + }, [send.price, minPrice]) + + const inputHint = t('price-switch.hintTip', { suggestFeeRate: minPrice }) + + const handlePriceChange = useCallback( + (e: React.SyntheticEvent) => { + const { value: inputValue } = e.currentTarget + + updateTransactionPrice(inputValue.replace(/,/g, '')) + }, + [updateTransactionPrice] + ) + + const getLastOutputAddress = (outputs: State.DetailedOutput[]) => { + if (outputs.length === 1) { + return scriptToAddress(outputs[0].lock, isMainnet) + } + + const change = outputs.find(output => { + const address = scriptToAddress(output.lock, isMainnet) + if (!isSecp256k1Address(address)) { + navigate(`${RoutePath.History}/amendSUDTSend/${hash}`, { + replace: true, + }) + } + + return !!addresses.find(item => item.address === address && item.type === 1) + }) + if (change) { + return scriptToAddress(change.lock, isMainnet) + } + + const receive = outputs.find(output => { + const address = scriptToAddress(output.lock, isMainnet) + return !!addresses.find(item => item.address === address && item.type === 0) + }) + if (receive) { + return scriptToAddress(receive.lock, isMainnet) + } + + return '' + } + + const items: { + address: string + amount: string + capacity: string + isLastOutput: boolean + output: State.DetailedOutput + }[] = useMemo(() => { + if (transaction && transaction.outputs.length) { + const lastOutputAddress = getLastOutputAddress(transaction.outputs) + return transaction.outputs.map(output => { + const address = scriptToAddress(output.lock, isMainnet) + return { + capacity: output.capacity, + address, + output, + amount: shannonToCKBFormatter(output.capacity || '0'), + isLastOutput: address === lastOutputAddress, + } + }) + } + return [] + }, [transaction?.outputs]) + + const outputsCapacity = useMemo(() => { + const outputList = items.filter(item => !item.isLastOutput) + return outputList.reduce((total, cur) => { + if (Number.isNaN(+(cur.capacity || ''))) { + return total + } + return total + BigInt(cur.capacity || '0') + }, BigInt(0)) + }, [items]) + + const totalAmount = shannonToCKBFormatter(outputsCapacity.toString()) + + const lastOutputsCapacity = useMemo(() => { + if (transaction) { + const inputsCapacity = transaction.inputs.reduce((total, cur) => { + if (Number.isNaN(+(cur.capacity || ''))) { + return total + } + return total + BigInt(cur.capacity || '0') + }, BigInt(0)) + + return inputsCapacity - outputsCapacity - fee + } + return -1 + }, [transaction, fee, outputsCapacity]) + + useEffect(() => { + if (transaction) { + const outputs = items.map(item => { + const capacity = item.isLastOutput ? lastOutputsCapacity.toString() : item.capacity + return { + ...item.output, + capacity, + } + }) + dispatch({ + type: AppActions.UpdateGeneratedTx, + payload: { + ...transaction, + outputs, + }, + }) + } + }, [lastOutputsCapacity, transaction, items, dispatch]) + + const disabled = sending || !send.generatedTx || priceError || lastOutputsCapacity < MIN_AMOUNT + + return ( + + +

{t('navbar.send')}

+ + } + > +
+
+
+
+ {items + .filter(item => items.length === 1 || !item.isLastOutput) + .map(item => ( +
+
+
{t('send.address')}
+
{item.address}
+
+ + +
+ ))} +
+
+ +
+
+
+

{t('send.total-amount')}

+

{totalAmount}

+
+ + + +
+
+ +
+ +
+
+
+
+
+ + +
+ ) +} + +AmendSend.displayName = 'AmendSend' + +export default AmendSend diff --git a/packages/neuron-ui/src/components/History/RowExtend.tsx b/packages/neuron-ui/src/components/History/RowExtend.tsx index 800557d238..980fb87b28 100644 --- a/packages/neuron-ui/src/components/History/RowExtend.tsx +++ b/packages/neuron-ui/src/components/History/RowExtend.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { showPageNotice, useDispatch } from 'states' import { openExternal } from 'services/remote' @@ -9,6 +9,9 @@ import { ExplorerIcon, Copy, DetailIcon } from 'widgets/Icons/icon' import { useTranslation } from 'react-i18next' import ShowOrEditDesc from 'widgets/ShowOrEditDesc' import Tooltip from 'widgets/Tooltip' +import { getTransaction as getOnChainTransaction } from 'services/chain' + +import Button from 'widgets/Button' import styles from './history.module.scss' type RowExtendProps = { @@ -23,6 +26,7 @@ const RowExtend = ({ column, columns, isMainnet, id, bestBlockNumber }: RowExten const dispatch = useDispatch() const navigate = useNavigate() const [t] = useTranslation() + const [amendabled, setAmendabled] = useState(false) const { onChangeEditStatus, onSubmitDescription } = useLocalDescription('transaction', id, dispatch) @@ -39,6 +43,14 @@ const RowExtend = ({ column, columns, isMainnet, id, bestBlockNumber }: RowExten navigate(`${RoutePath.History}/${btn.dataset.hash}`) break } + case 'amend': { + if (column?.sudtInfo) { + navigate(`${RoutePath.History}/amendSUDTSend/${btn.dataset.hash}`) + return + } + navigate(`${RoutePath.History}/amend/${btn.dataset.hash}`) + break + } default: { // ignore } @@ -48,7 +60,7 @@ const RowExtend = ({ column, columns, isMainnet, id, bestBlockNumber }: RowExten [isMainnet] ) - const { blockNumber, hash, description } = column + const { blockNumber, hash, description, status } = column const confirmations = blockNumber ? 1 + bestBlockNumber - +blockNumber : 0 const confirmationsLabel = confirmations > 1000 ? '1,000+' : localNumberFormatter(confirmations) const onCopy = useCallback(() => { @@ -56,6 +68,21 @@ const RowExtend = ({ column, columns, isMainnet, id, bestBlockNumber }: RowExten showPageNotice('common.copied')(dispatch) }, [hash, dispatch]) + useEffect(() => { + if (status !== 'success') { + if (column.type === 'send' && !column.nftInfo && !column.nervosDao) { + getOnChainTransaction(hash).then(tx => { + // @ts-expect-error Replace-By-Fee (RBF) + const { min_replace_fee: minReplaceFee } = tx + if (minReplaceFee) { + setAmendabled(true) + } + }) + } + } + setAmendabled(false) + }, [status, hash, setAmendabled]) + return ( @@ -83,7 +110,6 @@ const RowExtend = ({ column, columns, isMainnet, id, bestBlockNumber }: RowExten -
{t('history.transaction-hash')}
@@ -92,28 +118,40 @@ const RowExtend = ({ column, columns, isMainnet, id, bestBlockNumber }: RowExten
- - +
+ + +
+ + {amendabled ? ( + + ) : null}
diff --git a/packages/neuron-ui/src/components/History/history.module.scss b/packages/neuron-ui/src/components/History/history.module.scss index c749dc8d9e..2c68bdcd19 100644 --- a/packages/neuron-ui/src/components/History/history.module.scss +++ b/packages/neuron-ui/src/components/History/history.module.scss @@ -135,6 +135,14 @@ body { .infoOperationBox { display: flex; + justify-content: space-between; + margin-top: 20px; + + div { + display: flex; + gap: 20px; + flex: 1; + } gap: 20px; .explorerNavButton, @@ -156,6 +164,16 @@ body { } } } + + .amendButton { + float: rights; + padding: 4px 16px; + font-size: 14px; + height: auto; + width: auto; + min-width: 0; + border-radius: 4px; + } } } } diff --git a/packages/neuron-ui/src/components/History/hooks.ts b/packages/neuron-ui/src/components/History/hooks.ts index 19a40d836f..326b8ab8b5 100644 --- a/packages/neuron-ui/src/components/History/hooks.ts +++ b/packages/neuron-ui/src/components/History/hooks.ts @@ -1,6 +1,8 @@ import { useState, useEffect } from 'react' import { updateTransactionList } from 'states/stateProvider/actionCreators/transactions' -import { listParams, backToTop } from 'utils' +import { NeuronWalletActions } from 'states/stateProvider/reducer' +import { listParams, backToTop, isSuccessResponse } from 'utils' +import { getSUDTAccountList } from 'services/remote' export const useSearch = (search: string, walletID: string, dispatch: React.Dispatch) => { const [keywords, setKeywords] = useState('') @@ -18,6 +20,21 @@ export const useSearch = (search: string, walletID: string, dispatch: React.Disp setKeywords(params.keywords) setSortInfo({ sort: params.sort, direction: params.direction }) updateTransactionList({ ...params, keywords: params.keywords, walletID })(dispatch) + + getSUDTAccountList({ walletID }) + .then(res => { + if (isSuccessResponse(res)) { + return res.result + } + throw new Error(res.message.toString()) + }) + .then((list: Controller.GetSUDTAccountList.Response) => { + dispatch({ + type: NeuronWalletActions.GetSUDTAccountList, + payload: list, + }) + }) + .catch((err: Error) => console.error(err)) }, [search, walletID, dispatch]) return { keywords, onKeywordsChange, setKeywords, sortInfo } } diff --git a/packages/neuron-ui/src/components/PasswordRequest/hooks.ts b/packages/neuron-ui/src/components/PasswordRequest/hooks.ts index 6f8991b5d6..9e335c9e4e 100644 --- a/packages/neuron-ui/src/components/PasswordRequest/hooks.ts +++ b/packages/neuron-ui/src/components/PasswordRequest/hooks.ts @@ -45,7 +45,7 @@ export default ({ const [password, setPassword] = useState('') const [error, setError] = useState('') - const { walletID = '', actionType = null, multisigConfig, onSuccess } = passwordRequest + const { walletID = '', actionType = null, amendHash, multisigConfig, onSuccess } = passwordRequest useEffect(() => { setPassword('') @@ -139,7 +139,9 @@ export default ({ if (isSending) { break } - await sendTransaction({ walletID, tx: generatedTx, description, password })(dispatch).then(handleSendTxRes) + await sendTransaction({ walletID, tx: generatedTx, description, password, amendHash })(dispatch).then( + handleSendTxRes + ) break } case 'send-from-multisig-need-one': { @@ -231,6 +233,7 @@ export default ({ tx: experimental?.tx, password, skipLastInputs, + amendHash, } await sendSUDTTransaction(params)(dispatch).then(handleSendTxRes) break diff --git a/packages/neuron-ui/src/locales/en.json b/packages/neuron-ui/src/locales/en.json index c42810c3f5..4204d777e0 100644 --- a/packages/neuron-ui/src/locales/en.json +++ b/packages/neuron-ui/src/locales/en.json @@ -253,7 +253,9 @@ "release-on": "Release on", "locktime-warning": "Please ensure that receiver's wallet can support expiration unlocking. (Note: 1.Exchanges generally do not support expiration unlocking {{extraNote}})", "allow-use-sent-cell": "Unconfirmed Outputs are allowed in this transaction.", - "submit-transaction": "Submit the transaction" + "submit-transaction": "Submit the transaction", + "transaction-confirmed": "Transaction Confirmed", + "transaction-cannot-amend": "The current transaction is confirmed and cannot be amended." }, "receive": { "title": "Receive", @@ -323,6 +325,7 @@ "view-in-explorer-button-title": "View on explorer", "view-detail": "Detail", "view-detail-button-title": "View Detail", + "amend": "Amend", "opening": "Opening", "copy-tx-hash": "Copy Transaction Hash", "copy-balance": "Copy Balance", diff --git a/packages/neuron-ui/src/locales/es.json b/packages/neuron-ui/src/locales/es.json index 8d38510953..dc163b043b 100644 --- a/packages/neuron-ui/src/locales/es.json +++ b/packages/neuron-ui/src/locales/es.json @@ -245,7 +245,9 @@ "release-on": "Liberar en", "locktime-warning": "Asegúrese de que la billetera del receptor pueda soportar el desbloqueo por vencimiento. (Nota: 1. Generalmente, las casas de cambio no admiten el desbloqueo por vencimiento {{extraNote}})", "allow-use-sent-cell": "Se permite el uso de salidas no confirmadas en esta transacción.", - "submit-transaction": "Enviar la transacción" + "submit-transaction": "Enviar la transacción", + "transaction-confirmed": "Transacción confirmada", + "transaction-cannot-amend": "La transacción actual está confirmada y no puede modificarse." }, "receive": { "title": "Recibir", @@ -315,6 +317,7 @@ "view-in-explorer-button-title": "Ver en el explorador", "view-detail": "Detalle", "view-detail-button-title": "Ver Detalle", + "amend": "Enmendar", "opening": "Abriendo", "copy-tx-hash": "Copiar Hash de Transacción", "copy-balance": "Copiar Saldo", @@ -514,94 +517,94 @@ "title": "Enviar CKB desde dirección multifirma" }, "xpub-notice": "Para billetera de solo lectura, sólo es posible exportar transacciones" + }, + "qrcode": { + "copy": "Copiar imagen", + "save": "Guardar imagen" + }, + "common": { + "or": "o", + "ok": "Ok", + "dismiss": "Descartar", + "confirm": "Confirmar", + "open": "Abrir", + "cancel": "Cancelar", + "save": "Guardar", + "toggle": { + "on": "Encendido", + "off": "Apagado" }, - "qrcode": { - "copy": "Copiar imagen", - "save": "Guardar imagen" + "copy-tx-hash": "Copiar Hash de Transacción", + "copy-address": "Copiar Dirección", + "select": "Seleccionar", + "backup": "Respaldar", + "edit": "Editar", + "delete": "Borrar", + "click-to-edit": "Hacer clic para editar", + "notice": "Nota", + "experimental": "Experimental", + "close": "Cerrar", + "copy": "Copiar", + "copied": "Copiado", + "verification-failure": "Falló la Verificación", + "back": "Atrás", + "switch-to-light": "Cambiar a modo claro", + "switch-to-dark": "Cambiar a modo oscuro", + "ckb-node-data-path": "CKB Ruta de los datos del nodo" + }, + "notification-panel": { + "title": "Notificaciones" + }, + "message-types": { + "warning": "advertencia", + "alert": "alerta", + "success": "éxito" + }, + "messages": { + "error": "Error", + "unknown-error": "Error desconocido", + "update-wallet-successfully": "La billetera fue actualizada", + "delete-wallet-successfully": "La billetera fue eliminada", + "create-network-successfully": "La red fue creada", + "update-network-successfully": "La red fue actualizada", + "clear-cache-successfully": "El caché fue borrado", + "addr-copied": "La dirección ha sido copiada en el portapapeles", + "qrcode-copied": "El Código QR ha sido copiado en el portapapeles", + "view-the-run-node-doc": "Ver la guía en el navegador", + "remain-ckb-for-withdraw": "Es recomendable reservar algunos CKBytes para operaciones de retiro", + "no-valid-addresses-found": "No se encontraron direcciones válidas", + "decimal-range": "El decimal debe ser un número entero de {{range}}(incluido)", + "experimental-message-hardware": "Esta es una función experimental. Preste atención al riesgo y use con precaución.", + "experimental-message": "Esta es una función experimental, puede cambiar en cualquier momento. Por favor, use con precaución.", + "rebuild-sync": "Para una mejor experiencia de usuario, Neuron ha adoptado un nuevo almacenamiento, lo que requiere una migración de datos (estimado de 20 a 60 minutos).\nLo sentimos por la inconveniencia.", + "migrate-warning": "Advertencia: el proceso de migración puede fallar por razones desconocidas, lo que resulta en resincronización, ¡haga una copia de seguridad manualmente y comience la migración!", + "migrate-ckb-data": "Migrar", + "migrate": "Migrar", + "secp256k1/blake160-address-required": "Se requiere la dirección Secp256k1/Blake160", + "light-client-locktime-warning": "2. El modo de cliente ligero no admite mostrar CKBytes en tiempo de bloqueo.", + "light-client-cheque-warning": "Advertencia: el modo de cliente ligero no admite mostrar activos de Cheque.", + "fields": { + "wallet": "Billetera", + "name": "Nombre", + "password": "Contraseña", + "remote": "URL de RPC", + "network": "Red", + "address": "Dirección", + "amount": "Cantidad", + "transaction": "Transacción", + "default-address": "Dirección predeterminada", + "mnemonic": "Mnemónico", + "keystore-path": "Archivo Keystore", + "keystore-name": "Nombre de la billetera", + "keystore-password": "Contraseña", + "deposit": "Depósito", + "account-name": "Nombre de la cuenta", + "token-name": "Nombre del token", + "token-id": "ID del token", + "symbol": "Símbolo", + "decimal": "Decimal" }, - "common": { - "or": "o", - "ok": "Ok", - "dismiss": "Descartar", - "confirm": "Confirmar", - "open": "Abrir", - "cancel": "Cancelar", - "save": "Guardar", - "toggle": { - "on": "Encendido", - "off": "Apagado" - }, - "copy-tx-hash": "Copiar Hash de Transacción", - "copy-address": "Copiar Dirección", - "select": "Seleccionar", - "backup": "Respaldar", - "edit": "Editar", - "delete": "Borrar", - "click-to-edit": "Hacer clic para editar", - "notice": "Nota", - "experimental": "Experimental", - "close": "Cerrar", - "copy": "Copiar", - "copied": "Copiado", - "verification-failure": "Falló la Verificación", - "back": "Atrás", - "switch-to-light": "Cambiar a modo claro", - "switch-to-dark": "Cambiar a modo oscuro", - "ckb-node-data-path": "CKB Ruta de los datos del nodo" - }, - "notification-panel": { - "title": "Notificaciones" - }, - "message-types": { - "warning": "advertencia", - "alert": "alerta", - "success": "éxito" - }, - "messages": { - "error": "Error", - "unknown-error": "Error desconocido", - "update-wallet-successfully": "La billetera fue actualizada", - "delete-wallet-successfully": "La billetera fue eliminada", - "create-network-successfully": "La red fue creada", - "update-network-successfully": "La red fue actualizada", - "clear-cache-successfully": "El caché fue borrado", - "addr-copied": "La dirección ha sido copiada en el portapapeles", - "qrcode-copied": "El Código QR ha sido copiado en el portapapeles", - "view-the-run-node-doc": "Ver la guía en el navegador", - "remain-ckb-for-withdraw": "Es recomendable reservar algunos CKBytes para operaciones de retiro", - "no-valid-addresses-found": "No se encontraron direcciones válidas", - "decimal-range": "El decimal debe ser un número entero de {{range}}(incluido)", - "experimental-message-hardware": "Esta es una función experimental. Preste atención al riesgo y use con precaución.", - "experimental-message": "Esta es una función experimental, puede cambiar en cualquier momento. Por favor, use con precaución.", - "rebuild-sync": "Para una mejor experiencia de usuario, Neuron ha adoptado un nuevo almacenamiento, lo que requiere una migración de datos (estimado de 20 a 60 minutos).\nLo sentimos por la inconveniencia.", - "migrate-warning": "Advertencia: el proceso de migración puede fallar por razones desconocidas, lo que resulta en resincronización, ¡haga una copia de seguridad manualmente y comience la migración!", - "migrate-ckb-data": "Migrar", - "migrate": "Migrar", - "secp256k1/blake160-address-required": "Se requiere la dirección Secp256k1/Blake160", - "light-client-locktime-warning": "2. El modo de cliente ligero no admite mostrar CKBytes en tiempo de bloqueo.", - "light-client-cheque-warning": "Advertencia: el modo de cliente ligero no admite mostrar activos de Cheque.", - "fields": { - "wallet": "Billetera", - "name": "Nombre", - "password": "Contraseña", - "remote": "URL de RPC", - "network": "Red", - "address": "Dirección", - "amount": "Cantidad", - "transaction": "Transacción", - "default-address": "Dirección predeterminada", - "mnemonic": "Mnemónico", - "keystore-path": "Archivo Keystore", - "keystore-name": "Nombre de la billetera", - "keystore-password": "Contraseña", - "deposit": "Depósito", - "account-name": "Nombre de la cuenta", - "token-name": "Nombre del token", - "token-id": "ID del token", - "symbol": "Símbolo", - "decimal": "Decimal" - }, - "codes": { + "codes": { "-3": "", "100": "La cantidad no es suficiente.", "101": "La cantidad {{amount}} CKB es demasiado pequeña, por favor ingrese una cantidad no menor a {{required}} CKB.", @@ -706,18 +709,18 @@ "days-hours": "{{days}} días {{hours}} horas", "no-deposit": "Sin Depósito", "no-completed": "Sin Completar" - }, - "compensation-period": { - "tooltip": { - "compensated-period": "Período de Compensación", - "days-hours": "{{days}} días {{hours}} horas", - "immature-for-withdraw": "Se tarda al menos 16 horas (4epochs) para realizar la siguiente acción", - "normal": "La cantidad de compensación es baja, no se recomienda la acción de retiro", - "suggested": "Período de retiro sugerido para una compensación maximizada", - "ending": "Es posible que el retiro solicitado se posponga y se bloquee durante un ciclo adicional de 30 días", - "withdrawn": "Retirado" }, - "stage-messages": { + "compensation-period": { + "tooltip": { + "compensated-period": "Período de Compensación", + "days-hours": "{{days}} días {{hours}} horas", + "immature-for-withdraw": "Se tarda al menos 16 horas (4epochs) para realizar la siguiente acción", + "normal": "La cantidad de compensación es baja, no se recomienda la acción de retiro", + "suggested": "Período de retiro sugerido para una compensación maximizada", + "ending": "Es posible que el retiro solicitado se posponga y se bloquee durante un ciclo adicional de 30 días", + "withdrawn": "Retirado" + }, + "stage-messages": { "pending": "Pendiente...", "immature-for-withdraw": "No se puede retirar CKB durante las próximas {{ horas }} horas", "immature-for-unlock": "No se puede desbloquear CKB durante las próximas {{ horas }} horas", diff --git a/packages/neuron-ui/src/locales/fr.json b/packages/neuron-ui/src/locales/fr.json index 5cd1baf641..6a76f0d5e4 100644 --- a/packages/neuron-ui/src/locales/fr.json +++ b/packages/neuron-ui/src/locales/fr.json @@ -252,7 +252,9 @@ "release-on": "Libération le", "locktime-warning": "Veuillez vous assurer que le Wallet du destinataire peut prendre en charge le déverrouillage par expiration. (Remarque : 1. Les échanges ne prennent généralement pas en charge le déverrouillage par expiration {{extraNote}})", "allow-use-sent-cell": "Les sorties non confirmées sont autorisées dans cette transaction.", - "submit-transaction": "Soumettre la transaction" + "submit-transaction": "Soumettre la transaction", + "transaction-confirmed": "Transaction confirmée", + "transaction-cannot-amend": "La transaction en cours est confirmée et ne peut être modifiée." }, "receive": { "title": "Recevoir", @@ -322,6 +324,7 @@ "view-in-explorer-button-title": "Voir sur l'explorateur", "view-detail": "Détail", "view-detail-button-title": "Voir les détails", + "amend": "Modifier", "opening": "Ouverture", "copy-tx-hash": "Copier le hash de la transaction", "copy-balance": "Copier le solde", diff --git a/packages/neuron-ui/src/locales/zh-tw.json b/packages/neuron-ui/src/locales/zh-tw.json index 0208b9e9d4..32130fbf4e 100644 --- a/packages/neuron-ui/src/locales/zh-tw.json +++ b/packages/neuron-ui/src/locales/zh-tw.json @@ -247,7 +247,9 @@ "release-on": "鎖定至", "locktime-warning": "請確保對方錢包支持到期解鎖功能。(註:1.交易所壹般不支持到期解鎖功能 {{extraNote}})", "allow-use-sent-cell": "允許使用待確認的 Outputs 構建交易", - "submit-transaction": "發起交易" + "submit-transaction": "發起交易", + "transaction-confirmed": "交易已確認", + "transaction-cannot-amend": "目前交易已確認,無法修改。" }, "receive": { "title": "收款", @@ -316,6 +318,7 @@ "no-txs": "沒有交易記錄", "view-in-explorer": "瀏覽器打開", "view-in-explorer-button-title": "瀏覽器中查看詳情", + "amend": "修改", "view-detail": "詳情", "view-detail-button-title": "查看詳情", "opening": "正在打開", diff --git a/packages/neuron-ui/src/locales/zh.json b/packages/neuron-ui/src/locales/zh.json index fbb85cf7af..bef868f0b4 100644 --- a/packages/neuron-ui/src/locales/zh.json +++ b/packages/neuron-ui/src/locales/zh.json @@ -246,7 +246,9 @@ "release-on": "锁定至", "locktime-warning": "请确保对方钱包支持到期解锁功能。(注:1.交易所一般不支持到期解锁功能 {{extraNote}})", "allow-use-sent-cell": "允许使用待确认的 Outputs 构建交易", - "submit-transaction": "发起交易" + "submit-transaction": "发起交易", + "transaction-confirmed": "交易已确认", + "transaction-cannot-amend": "当前交易已确认,无法修改。" }, "receive": { "title": "收款", @@ -316,6 +318,7 @@ "view-in-explorer-button-title": "浏览器中查看详情", "view-detail": "详情", "view-detail-button-title": "查看详情", + "amend": "修改", "opening": "正在打开", "copy-tx-hash": "复制交易哈希", "copy-balance": "复制余额", diff --git a/packages/neuron-ui/src/router.tsx b/packages/neuron-ui/src/router.tsx index 8e197d8bfa..319ebc3274 100644 --- a/packages/neuron-ui/src/router.tsx +++ b/packages/neuron-ui/src/router.tsx @@ -9,6 +9,8 @@ import ImportKeystore from 'components/ImportKeystore' import Send from 'components/Send' import History from 'components/History' import HistoryDetailPage from 'components/HistoryDetailPage' +import AmendSend from 'components/AmendSend' +import AmendSUDTSend from 'components/AmendSUDTSend' import LaunchScreen from 'components/LaunchScreen' import PasswordRequest from 'components/PasswordRequest' import NervosDAO from 'components/NervosDAO' @@ -142,6 +144,26 @@ const mainRouterConfig: RouteObject[] = [ ), children: [...toolsRouters], }, + { + path: 'amend/:hash', + element: ( + <> + + + + ), + children: [...toolsRouters], + }, + { + path: 'amendSUDTSend/:hash', + element: ( + <> + + + + ), + children: [...toolsRouters], + }, { path: ':hash', element: ( diff --git a/packages/neuron-ui/src/services/remote/offline.ts b/packages/neuron-ui/src/services/remote/offline.ts index 1743cd2a46..f2ed6f88e3 100644 --- a/packages/neuron-ui/src/services/remote/offline.ts +++ b/packages/neuron-ui/src/services/remote/offline.ts @@ -44,6 +44,7 @@ export const exportTransactionAsJSON = remoteApi('export- export const signTransactionOnly = remoteApi('sign-transaction-only') export const broadcastTransaction = remoteApi('broadcast-transaction-only') export const broadcastSignedTransaction = remoteApi('broadcast-signed-transaction') +export const getTransactionSize = remoteApi('get-transaction-size') export const signAndExportTransaction = remoteApi( 'sign-and-export-transaction' ) diff --git a/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts b/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts index a1f270ce63..5be98553fc 100644 --- a/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts +++ b/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts @@ -139,6 +139,7 @@ type Action = | 'sign-transaction-only' | 'broadcast-transaction-only' | 'broadcast-signed-transaction' + | 'get-transaction-size' | 'sign-and-export-transaction' | 'sign-and-broadcast-transaction' // nft diff --git a/packages/neuron-ui/src/types/App/index.d.ts b/packages/neuron-ui/src/types/App/index.d.ts index b8559efbb0..4ba289b716 100644 --- a/packages/neuron-ui/src/types/App/index.d.ts +++ b/packages/neuron-ui/src/types/App/index.d.ts @@ -127,6 +127,7 @@ declare namespace State { } onSuccess?: () => void showType?: 'Global' | '' + amendHash?: string } interface SUDTAccount { diff --git a/packages/neuron-ui/src/types/Controller/index.d.ts b/packages/neuron-ui/src/types/Controller/index.d.ts index 5be3667b02..c308f2b88b 100644 --- a/packages/neuron-ui/src/types/Controller/index.d.ts +++ b/packages/neuron-ui/src/types/Controller/index.d.ts @@ -69,6 +69,7 @@ declare namespace Controller { tx: any password?: string description?: string + amendHash?: string multisigConfig?: { id: number walletId: string @@ -289,6 +290,7 @@ declare namespace Controller { tx: any password?: string skipLastInputs?: boolean + amendHash?: string } type Response = Hash } diff --git a/packages/neuron-wallet/src/block-sync-renderer/tx-status-listener.ts b/packages/neuron-wallet/src/block-sync-renderer/tx-status-listener.ts index 1fbba05c50..1a2532559b 100644 --- a/packages/neuron-wallet/src/block-sync-renderer/tx-status-listener.ts +++ b/packages/neuron-wallet/src/block-sync-renderer/tx-status-listener.ts @@ -42,6 +42,8 @@ const getTransactionStatus = async (hash: string) => { const trackingStatus = async () => { const pendingTransactions = await FailedTransaction.pendings() + await FailedTransaction.processAmendFailedTxs() + if (!pendingTransactions.length) { return } diff --git a/packages/neuron-wallet/src/controllers/anyone-can-pay.ts b/packages/neuron-wallet/src/controllers/anyone-can-pay.ts index b74267ea19..df092470bf 100644 --- a/packages/neuron-wallet/src/controllers/anyone-can-pay.ts +++ b/packages/neuron-wallet/src/controllers/anyone-can-pay.ts @@ -22,6 +22,7 @@ export interface SendAnyoneCanPayTxParams { tx: Transaction password: string skipLastInputs?: boolean + amendHash?: string } export default class AnyoneCanPayController { @@ -65,7 +66,8 @@ export default class AnyoneCanPayController { txModel, params.password, params?.skipLastInputs ?? true, - skipSign + skipSign, + params?.amendHash ) if (!txHash) { diff --git a/packages/neuron-wallet/src/controllers/api.ts b/packages/neuron-wallet/src/controllers/api.ts index d442eaefe7..bca1118731 100644 --- a/packages/neuron-wallet/src/controllers/api.ts +++ b/packages/neuron-wallet/src/controllers/api.ts @@ -416,6 +416,7 @@ export default class ApiController { password: string description?: string multisigConfig?: MultisigConfigModel + amendHash?: string } ) => { return this.#walletsController.sendTx({ @@ -840,6 +841,10 @@ export default class ApiController { return this.#offlineSignController.broadcastTransaction({ ...params, walletID: '' }) }) + handle('get-transaction-size', async (_, params) => { + return this.#transactionsController.getTransactionSize(params) + }) + handle('sign-and-export-transaction', async (_, params) => { return this.#offlineSignController.signAndExportTransaction({ ...params, diff --git a/packages/neuron-wallet/src/controllers/transactions.ts b/packages/neuron-wallet/src/controllers/transactions.ts index eb3a4fabfc..193d7c7565 100644 --- a/packages/neuron-wallet/src/controllers/transactions.ts +++ b/packages/neuron-wallet/src/controllers/transactions.ts @@ -10,6 +10,7 @@ import Transaction from '../models/chain/transaction' import { set as setDescription, get as getDescription } from '../services/tx/transaction-description' import ShowGlobalDialogSubject from '../models/subjects/show-global-dialog' +import TransactionSize from '../models/transaction-size' export default class TransactionsController { public async getAll( @@ -146,4 +147,12 @@ export default class TransactionsController { throw err } } + + public async getTransactionSize(tx: Transaction) { + const size = TransactionSize.tx(Transaction.fromObject(tx)) + return { + status: ResponseCode.Success, + result: size, + } + } } diff --git a/packages/neuron-wallet/src/controllers/wallets.ts b/packages/neuron-wallet/src/controllers/wallets.ts index 61b972a0b4..08203144c7 100644 --- a/packages/neuron-wallet/src/controllers/wallets.ts +++ b/packages/neuron-wallet/src/controllers/wallets.ts @@ -416,6 +416,7 @@ export default class WalletsController { password: string description?: string multisigConfig?: MultisigConfigModel + amendHash?: string }, skipSign = false ) { @@ -438,7 +439,8 @@ export default class WalletsController { Transaction.fromObject(params.tx), params.password, false, - skipSign + skipSign, + params.amendHash ) } const description = params.description || params.tx.description || '' diff --git a/packages/neuron-wallet/src/database/chain/entities/amend-transaction.ts b/packages/neuron-wallet/src/database/chain/entities/amend-transaction.ts new file mode 100644 index 0000000000..8bca7a08db --- /dev/null +++ b/packages/neuron-wallet/src/database/chain/entities/amend-transaction.ts @@ -0,0 +1,31 @@ +import { Column, PrimaryGeneratedColumn, Entity } from 'typeorm' + +@Entity() +export default class AmendTransaction { + @PrimaryGeneratedColumn() + id!: number + + @Column({ + type: 'varchar', + }) + hash!: string + + @Column({ + type: 'varchar', + }) + amendHash!: string + + static fromObject(params: { hash: string; amendHash: string }) { + const res = new AmendTransaction() + res.hash = params.hash + res.amendHash = params.amendHash + return res + } + + public toModel() { + return { + hash: this.hash, + amendHash: this.amendHash, + } + } +} diff --git a/packages/neuron-wallet/src/database/chain/index.ts b/packages/neuron-wallet/src/database/chain/index.ts index fcb13996e9..95a2a7a298 100644 --- a/packages/neuron-wallet/src/database/chain/index.ts +++ b/packages/neuron-wallet/src/database/chain/index.ts @@ -8,6 +8,7 @@ import IndexerTxHashCache from './entities/indexer-tx-hash-cache' import MultisigOutput from './entities/multisig-output' import SyncProgress from './entities/sync-progress' import TxLock from './entities/tx-lock' +import AmendTransactionEntity from './entities/amend-transaction' import { getConnection } from '../../database/chain/connection' /* @@ -15,7 +16,15 @@ import { getConnection } from '../../database/chain/connection' */ export const clean = async (clearAllLightClientData?: boolean) => { await Promise.all([ - ...[InputEntity, OutputEntity, TransactionEntity, IndexerTxHashCache, MultisigOutput, TxLock].map(entity => { + ...[ + InputEntity, + OutputEntity, + TransactionEntity, + IndexerTxHashCache, + MultisigOutput, + TxLock, + AmendTransactionEntity, + ].map(entity => { return getConnection().getRepository(entity).clear() }), clearAllLightClientData diff --git a/packages/neuron-wallet/src/database/chain/migrations/1709008125088-AmendTransaction.ts b/packages/neuron-wallet/src/database/chain/migrations/1709008125088-AmendTransaction.ts new file mode 100644 index 0000000000..d6578a0dac --- /dev/null +++ b/packages/neuron-wallet/src/database/chain/migrations/1709008125088-AmendTransaction.ts @@ -0,0 +1,13 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class AmendTransaction1709008125088 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "amend_transaction" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "hash" varchar NOT NULL, "amendHash" varchar NOT NULL)`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "amend_transaction"`) + } + +} diff --git a/packages/neuron-wallet/src/database/chain/ormconfig.ts b/packages/neuron-wallet/src/database/chain/ormconfig.ts index a4c6c134d7..ac120deeca 100644 --- a/packages/neuron-wallet/src/database/chain/ormconfig.ts +++ b/packages/neuron-wallet/src/database/chain/ormconfig.ts @@ -21,6 +21,7 @@ import MultisigOutput from './entities/multisig-output' import SyncProgress from './entities/sync-progress' import TxLock from './entities/tx-lock' import CellLocalInfo from './entities/cell-local-info' +import AmendTransaction from './entities/amend-transaction' import { InitMigration1566959757554 } from './migrations/1566959757554-InitMigration' import { AddTypeAndHasData1567144517514 } from './migrations/1567144517514-AddTypeAndHasData' @@ -61,6 +62,7 @@ import { IndexerTxHashCacheRemoveField1701234043431 } from './migrations/1701234 import { CreateCellLocalInfo1701234043432 } from './migrations/1701234043432-CreateCellLocalInfo' import { RenameSyncProgress1702781527414 } from './migrations/1702781527414-RenameSyncProgress' import { RemoveAddressInIndexerCache1704357651876 } from './migrations/1704357651876-RemoveAddressInIndexerCache' +import { AmendTransaction1709008125088 } from './migrations/1709008125088-AmendTransaction' import { ConnectionName } from './connection' import AddressSubscribe from './subscriber/address-subscriber' import MultisigConfigSubscribe from './subscriber/multisig-config-subscriber' @@ -106,6 +108,7 @@ const connectOptions = async ( SyncProgress, TxLock, CellLocalInfo, + AmendTransaction, ], migrations: [ InitMigration1566959757554, @@ -147,6 +150,7 @@ const connectOptions = async ( CreateCellLocalInfo1701234043432, RenameSyncProgress1702781527414, RemoveAddressInIndexerCache1704357651876, + AmendTransaction1709008125088, ], subscribers: [ AddressSubscribe, diff --git a/packages/neuron-wallet/src/services/amend-transaction.ts b/packages/neuron-wallet/src/services/amend-transaction.ts new file mode 100644 index 0000000000..9933787483 --- /dev/null +++ b/packages/neuron-wallet/src/services/amend-transaction.ts @@ -0,0 +1,21 @@ +import { getConnection } from '../database/chain/connection' +import AmendTransactionEntity from '../database/chain/entities/amend-transaction' + +export default class AmendTransactionService { + static async save(hash: string, amendHash: string) { + const exist = await getConnection().getRepository(AmendTransactionEntity).findOne({ + hash, + amendHash, + }) + if (exist) { + return + } + + const amendTransactionEntity = AmendTransactionEntity.fromObject({ + hash, + amendHash, + }) + + return await getConnection().manager.save(amendTransactionEntity) + } +} diff --git a/packages/neuron-wallet/src/services/transaction-sender.ts b/packages/neuron-wallet/src/services/transaction-sender.ts index b8bc3f5962..d91b4e7b74 100644 --- a/packages/neuron-wallet/src/services/transaction-sender.ts +++ b/packages/neuron-wallet/src/services/transaction-sender.ts @@ -37,6 +37,7 @@ import AssetAccountInfo from '../models/asset-account-info' import MultisigConfigModel from '../models/multisig-config' import { Hardware } from './hardware/hardware' import MultisigService from './multisig' +import AmendTransactionService from './amend-transaction' import { getMultisigStatus } from '../utils/multisig' import { SignStatus } from '../models/offline-sign' import NetworksService from './networks' @@ -68,13 +69,14 @@ export default class TransactionSender { transaction: Transaction, password: string = '', skipLastInputs: boolean = true, - skipSign = false + skipSign = false, + amendHash = '' ) { const tx = skipSign ? Transaction.fromObject(transaction) : await this.sign(walletID, transaction, password, skipLastInputs) - return this.broadcastTx(walletID, tx) + return this.broadcastTx(walletID, tx, amendHash) } public async sendMultisigTx( @@ -91,7 +93,7 @@ export default class TransactionSender { return this.broadcastTx(walletID, tx) } - public async broadcastTx(walletID: string = '', tx: Transaction) { + public async broadcastTx(walletID: string = '', tx: Transaction, amendHash = '') { const currentNetwork = NetworksService.getInstance().getCurrent() const rpc = generateRPC(currentNetwork.remote, currentNetwork.type) await rpc.sendTransaction(tx.toSDKRawTransaction(), 'passthrough') @@ -99,6 +101,9 @@ export default class TransactionSender { await TransactionPersistor.saveSentTx(tx, txHash) await MultisigService.saveSentMultisigOutput(tx) + if (amendHash) { + await AmendTransactionService.save(txHash, amendHash) + } if (walletID) { const wallet = WalletService.getInstance().get(walletID) diff --git a/packages/neuron-wallet/src/services/tx/failed-transaction.ts b/packages/neuron-wallet/src/services/tx/failed-transaction.ts index 6841cedca1..996c53d336 100644 --- a/packages/neuron-wallet/src/services/tx/failed-transaction.ts +++ b/packages/neuron-wallet/src/services/tx/failed-transaction.ts @@ -1,10 +1,12 @@ -import { In } from 'typeorm' +import { In, Not } from 'typeorm' import { getConnection } from '../../database/chain/connection' import OutputEntity from '../../database/chain/entities/output' +import InputEntity from '../../database/chain/entities/input' import TransactionEntity from '../../database/chain/entities/transaction' import { OutputStatus } from '../../models/chain/output' import TransactionsService from './transaction-service' import { TransactionStatus } from '../../models/chain/transaction' +import AmendTransactionEntity from '../../database/chain/entities/amend-transaction' export class FailedTransaction { public static pendings = async (): Promise => { @@ -19,6 +21,65 @@ export class FailedTransaction { return pendingTransactions } + public static processAmendFailedTxs = async () => { + const failedTransactions = await getConnection() + .getRepository(TransactionEntity) + .createQueryBuilder('tx') + .where({ + status: TransactionStatus.Failed, + }) + .select('tx.hash', 'hash') + .getRawMany() + + if (!failedTransactions.length) return + + const failedHashes = failedTransactions.map(item => item.hash) + + const amendTxs = await getConnection() + .getRepository(AmendTransactionEntity) + .createQueryBuilder('amend') + .where([ + { amendHash: In(failedHashes), hash: Not(In(failedHashes)) }, + { hash: In(failedHashes), amendHash: Not(In(failedHashes)) }, + ]) + .getMany() + + if (!amendTxs) return + + const removeTxs: string[] = [] + amendTxs.forEach(({ hash, amendHash }) => { + if (failedHashes.includes(hash)) { + removeTxs.push(hash) + } + if (failedHashes.includes(amendHash)) { + removeTxs.push(amendHash) + } + }) + + await getConnection().manager.transaction(async transactionalEntityManager => { + await transactionalEntityManager + .createQueryBuilder() + .delete() + .from(TransactionEntity) + .where({ hash: In(removeTxs) }) + .execute() + + await transactionalEntityManager + .createQueryBuilder() + .delete() + .from(OutputEntity) + .where({ outPointTxHash: In(removeTxs) }) + .execute() + + await transactionalEntityManager + .createQueryBuilder() + .delete() + .from(InputEntity) + .where({ outPointTxHash: In(removeTxs) }) + .execute() + }) + } + // update tx status to TransactionStatus.Failed // update outputs status to OutputStatus.Failed // update previous outputs (inputs) to OutputStatus.Live