Skip to content

Commit

Permalink
feat: Supports amend transactions
Browse files Browse the repository at this point in the history
  • Loading branch information
devchenyan committed Apr 19, 2024
1 parent ab2e85d commit 9b29b3d
Show file tree
Hide file tree
Showing 8 changed files with 475 additions and 81 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@import '../../styles/mixin.scss';

.content {
width: 680px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import React, { useState, useCallback, useEffect, useMemo } from 'react'
import { PasswordIncorrectException } from 'exceptions'
import { TFunction } from 'i18next'
import { getTransaction as getOnChainTransaction } from 'services/chain'
import {
getTransaction as getSentTransaction,
getTransactionSize,
sendTx,
invokeShowErrorMessage,
} from 'services/remote'
import { isSuccessResponse, ErrorCode } from 'utils'

export const useInitialize = ({
hash,
walletID,
t,
onClose,
}: {
hash: string
walletID: string
t: TFunction
onClose: () => void
}) => {
const [transaction, setTransaction] = useState<State.GeneratedTx | null>(null)
const [generatedTx, setGeneratedTx] = useState<State.GeneratedTx | null>(null)
const [size, setSize] = useState(0)
const [minPrice, setMinPrice] = useState('0')
const [price, setPrice] = useState('0')
const [password, setPassword] = useState('')
const [pwdError, setPwdError] = useState('')
const [sending, setSending] = useState(false)

const [showConfirmedAlert, setShowConfirmedAlert] = useState(false)

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)
setPrice(mPrice)
}
}
}
}, [hash, setShowConfirmedAlert, setPrice, setTransaction, setSize, setMinPrice])

useEffect(() => {
fetchInitData()
}, [])

const onPwdChange = useCallback(
(e: React.SyntheticEvent<HTMLInputElement>) => {
const { value } = e.target as HTMLInputElement
setPassword(value)
setPwdError('')
},
[setPassword, setPwdError]
)

const onSubmit = useCallback(async () => {
try {
// @ts-expect-error Replace-By-Fee (RBF)
const { min_replace_fee: minFee } = await getOnChainTransaction(hash)
if (!minFee) {
setShowConfirmedAlert(true)
return
}

if (!generatedTx) {
return
}
setSending(true)

try {
const skipLastInputs = generatedTx.inputs.length > generatedTx.witnesses.length

const res = await sendTx({ walletID, tx: generatedTx, password, skipLastInputs, amendHash: hash })

if (isSuccessResponse(res)) {
onClose()
} else if (res.status === ErrorCode.PasswordIncorrect) {
setPwdError(t(new PasswordIncorrectException().message))
} else {
invokeShowErrorMessage({
title: t('messages.error'),
content: typeof res.message === 'string' ? res.message : res.message.content!,
})
}
} catch (err) {
console.warn(err)
} finally {
setSending(false)
}
} catch {
// ignore
}
}, [walletID, hash, setShowConfirmedAlert, setPwdError, password, generatedTx, setSending])

return {
fee,
price,
setPrice,
generatedTx,
setGeneratedTx,
transaction,
setTransaction,
minPrice,
showConfirmedAlert,
onSubmit,
password,
onPwdChange,
pwdError,
sending,
setSending,
}
}

export default {
useInitialize,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import React, { useEffect, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useState as useGlobalState } from 'states'
import TextField from 'widgets/TextField'
import Dialog from 'widgets/Dialog'
import { MIN_AMOUNT } from 'utils/const'
import { scriptToAddress } from '@nervosnetwork/ckb-sdk-utils'
import { isMainnet as isMainnetUtil, localNumberFormatter, shannonToCKBFormatter } from 'utils'
import AlertDialog from 'widgets/AlertDialog'
import styles from './amendPendingTransactionDialog.module.scss'
import { useInitialize } from './hooks'

const AmendPendingTransactionDialog = ({ hash, onClose }: { hash: string; onClose: () => void }) => {
const {
wallet: { id: walletID = '', addresses },
chain: { networkID },
settings: { networks = [] },
sUDTAccounts,
} = useGlobalState()
const { t } = useTranslation()

const isMainnet = isMainnetUtil(networks, networkID)

const {
fee,
price,
setPrice,
transaction,
onSubmit,
minPrice,
showConfirmedAlert,
password,
onPwdChange,
pwdError,
generatedTx,
setGeneratedTx,
sending,
} = useInitialize({
hash,
walletID,
t,
onClose,
})

const priceError = useMemo(() => {
return Number(price) < Number(minPrice) ? t('price-switch.errorTip', { minPrice }) : null
}, [price, minPrice])

const inputHint = t('price-switch.hintTip', { suggestFeeRate: minPrice })

const handlePriceChange = useCallback(
(e: React.SyntheticEvent<HTMLInputElement>) => {
const { value: inputValue } = e.currentTarget

const value = inputValue.split('.')[0].replace(/[^\d]/, '')
setPrice(value)
},
[setPrice]
)

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)
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,
}
})
setGeneratedTx({
...transaction,
outputs,
})
}
}, [lastOutputsCapacity, transaction, items, setGeneratedTx])

const disabled = !!(sending || !generatedTx || priceError || lastOutputsCapacity < MIN_AMOUNT)

return (
<>
<Dialog
show
title={t('Amend Pending Transaction')}
onCancel={onClose}
onConfirm={onSubmit}
confirmText={t('send.send')}
disabled={disabled}
isLoading={sending}
>
<div className={styles.content}>
<TextField
label={t('send.fee')}
field="fee"
value={`${shannonToCKBFormatter(fee.toString())} CKB`}
readOnly
disabled
width="100%"
/>
<TextField
label={t('price-switch.customPrice')}
field="price"
value={localNumberFormatter(price)}
onChange={handlePriceChange}
suffix="shannons/kB"
error={priceError}
hint={!priceError && inputHint ? inputHint : null}
width="100%"
/>

<TextField
className={styles.passwordInput}
placeholder={t('password-request.placeholder')}
width="100%"
label={t('password-request.password')}
value={password}
field="password"
type="password"
title={t('password-request.password')}
onChange={onPwdChange}
autoFocus
error={pwdError}
/>
</div>
</Dialog>
<AlertDialog
show={showConfirmedAlert}
title={t('send.transaction-confirmed')}
message={t('send.transaction-cannot-amend')}
type="warning"
onOk={onClose}
action="ok"
/>
</>
)
}

AmendPendingTransactionDialog.displayName = 'AmendPendingTransactionDialog'

export default AmendPendingTransactionDialog
Loading

1 comment on commit 9b29b3d

@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 8747252812

Please sign in to comment.