From 0ecb8adc39badd63140ee67c4c5df96cbadbd0f2 Mon Sep 17 00:00:00 2001 From: Tim Man Date: Fri, 26 Jul 2024 16:41:33 +0800 Subject: [PATCH] refactor: move btc tx summary and runes summary parse logic into context (#417) * refactor: move btc tx summary and runes summary parse logic into context refactor: move btc tx summary and runes summary parse logic into context refactor: move btc tx summary and runes summary parse logic into context and split the transferSection with youWillSendSection refactor: move btc tx summary and runes summary parse logic into context refactor: move btc tx summary and runes summary parse logic into context and split the transferSection with youWillSendSection * chore: do TODO for a core type * apply suggestions * use newest core * unused import * refactor naming * prepare todos * commit work * revert * rebase new work * fix some bugs * fix linting * Update src/app/screens/sendRune/index.tsx Co-authored-by: Tim Man * review * rename * touchup + add next todo * update todo * remove unnecessary data imo * Fix playwright-report upload for PR workflow * touchup --------- Co-authored-by: Terence Ng Co-authored-by: Christine Pinto --- .github/workflows/build.yml | 2 +- .../hooks/useParsedTxSummaryContext.ts | 214 ++++++++++++++++++ .../confirmBtcTransaction/index.tsx | 58 ++--- .../confirmBtcTransaction/mintSection.tsx | 44 ++-- .../confirmBtcTransaction/receiveSection.tsx | 103 +++------ .../confirmBtcTransaction/sendSection.tsx | 161 +++++++++++++ .../transactionSummary.tsx | 149 +++++------- .../confirmBtcTransaction/transferSection.tsx | 162 ++++--------- .../txInOutput/txInOutput.tsx | 14 +- .../components/confirmBtcTransaction/utils.ts | 33 ++- src/app/routes/index.tsx | 3 + .../SendInscriptionsRequest.tsx | 7 +- .../useSendInscriptions.ts | 16 +- src/app/screens/etchRune/index.tsx | 19 +- src/app/screens/mintRune/index.tsx | 29 ++- src/app/screens/mintRune/useMintRequest.ts | 16 +- .../restoreFunds/recoverRunes/index.tsx | 11 +- src/app/screens/sendBtc/index.tsx | 41 +++- src/app/screens/sendBtc/stepDisplay.tsx | 13 +- src/app/screens/sendOrdinal/index.tsx | 31 ++- src/app/screens/sendOrdinal/stepDisplay.tsx | 15 +- src/app/screens/sendRune/index.tsx | 7 +- src/app/screens/sendRune/stepDisplay.tsx | 5 +- .../screens/signBatchPsbtRequest/index.tsx | 199 +++++++--------- src/app/screens/signPsbtRequest/index.tsx | 17 +- .../psbtConfirmation/psbtConfirmation.tsx | 12 +- 26 files changed, 819 insertions(+), 562 deletions(-) create mode 100644 src/app/components/confirmBtcTransaction/hooks/useParsedTxSummaryContext.ts create mode 100644 src/app/components/confirmBtcTransaction/sendSection.tsx diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ca9d2c73d..a706c7343 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,7 +48,7 @@ jobs: run: xvfb-run --auto-servernum --server-args="-screen 0 360x360x24" npm run e2etest:smoketest --reporter=html - name: Upload Playwright report if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: playwright-report path: playwright-report/ diff --git a/src/app/components/confirmBtcTransaction/hooks/useParsedTxSummaryContext.ts b/src/app/components/confirmBtcTransaction/hooks/useParsedTxSummaryContext.ts new file mode 100644 index 000000000..f19989123 --- /dev/null +++ b/src/app/components/confirmBtcTransaction/hooks/useParsedTxSummaryContext.ts @@ -0,0 +1,214 @@ +import { + getInputsWithAssetsFromUserAddress, + getNetAmount, + getOutputsWithAssetsFromUserAddress, + getOutputsWithAssetsToUserAddress, + isScriptOutput, +} from '@components/confirmBtcTransaction/utils'; +import useSelectedAccount from '@hooks/useSelectedAccount'; +import useWalletSelector from '@hooks/useWalletSelector'; +import type { TransactionSummary } from '@screens/sendBtc/helpers'; +import { + type btcTransaction, + type RuneSummary, + type RuneSummaryActions, +} from '@secretkeylabs/xverse-core'; +import { createContext, useContext } from 'react'; + +export type ParsedTxSummaryContextProps = { + summary?: TransactionSummary | btcTransaction.PsbtSummary; + runeSummary?: RuneSummary | RuneSummaryActions; +}; + +export const ParsedTxSummaryContext = createContext({ + summary: undefined, + runeSummary: undefined, +}); + +export const useParsedTxSummaryContext = (): { + summary: + | (TransactionSummary & { dustFiltered?: boolean }) + | btcTransaction.PsbtSummary + | undefined; + runeSummary: RuneSummary | RuneSummaryActions | undefined; + hasExternalInputs: boolean; + isUnconfirmedInput: boolean; + showCenotaphCallout: boolean; + netBtcAmount: number; + hasInsufficientRunes: boolean; + hasSigHashSingle: boolean; + transactionIsFinal: boolean; + hasOutputScript: boolean; + hasSigHashNone: boolean; + validMintingRune: undefined | boolean; + sendSection: { + showSendSection: boolean; + showBtcAmount: boolean; + showRuneTransfers: boolean; + hasInscriptionsRareSatsInOrdinal: boolean; + outputsFromOrdinal: ( + | btcTransaction.TransactionOutput + | btcTransaction.TransactionPubKeyOutput + )[]; + inputFromOrdinal: btcTransaction.EnhancedInput[]; + inscriptionsFromPayment: btcTransaction.IOInscription[]; + satributesFromPayment: btcTransaction.IOSatribute[]; + }; + receiveSection: { + showBtcAmount: boolean; + showOrdinalSection: boolean; + showPaymentSection: boolean; + outputsToOrdinal: btcTransaction.TransactionOutput[]; + showPaymentRunes: boolean; + ordinalRuneReceipts: RuneSummary['receipts']; + inscriptionsRareSatsInPayment: btcTransaction.TransactionOutput[]; + outputsToPayment: btcTransaction.TransactionOutput[]; + showOrdinalRunes: boolean; + paymentRuneReceipts: RuneSummary['receipts']; + }; +} => { + const { summary, runeSummary } = useContext(ParsedTxSummaryContext); + const { btcAddress, ordinalsAddress } = useSelectedAccount(); + const { hasActivatedRareSatsKey } = useWalletSelector(); + + const showCenotaphCallout = !!summary?.runeOp?.Cenotaph?.flaws; + const hasInsufficientRunes = + runeSummary?.transfers?.some((transfer) => !transfer.hasSufficientBalance) ?? false; + const validMintingRune = + !runeSummary?.mint || + (runeSummary?.mint && runeSummary.mint.runeIsOpen && runeSummary.mint.runeIsMintable); + const hasOutputScript = summary?.outputs.some((output) => isScriptOutput(output)) ?? false; + const hasExternalInputs = + summary?.inputs.some( + (input) => + input.extendedUtxo.address !== btcAddress && input.extendedUtxo.address !== ordinalsAddress, + ) ?? false; + const isUnconfirmedInput = + summary?.inputs.some( + (input) => !input.extendedUtxo.utxo.status.confirmed && input.walletWillSign, + ) ?? false; + + const netBtcAmount = getNetAmount({ + inputs: summary?.inputs, + outputs: summary?.outputs, + btcAddress, + ordinalsAddress, + }); + + // defaults for non-psbt transactions + const transactionIsFinal = (summary as btcTransaction.PsbtSummary)?.isFinal ?? true; + const hasSigHashNone = (summary as btcTransaction.PsbtSummary)?.hasSigHashNone ?? false; + const hasSigHashSingle = (summary as btcTransaction.PsbtSummary)?.hasSigHashSingle ?? false; + + /* Send/Transfer section */ + + const { inputFromPayment, inputFromOrdinal } = getInputsWithAssetsFromUserAddress({ + inputs: summary?.inputs, + btcAddress, + ordinalsAddress, + }); + + const { outputsFromPayment, outputsFromOrdinal } = getOutputsWithAssetsFromUserAddress({ + outputs: summary?.outputs, + btcAddress, + ordinalsAddress, + }); + + // TODO move to utils + const inscriptionsFromPayment: btcTransaction.IOInscription[] = []; + const satributesFromPayment: btcTransaction.IOSatribute[] = []; + (transactionIsFinal ? outputsFromPayment : inputFromPayment).forEach( + ( + item: + | btcTransaction.EnhancedInput + | btcTransaction.TransactionOutput + | btcTransaction.TransactionPubKeyOutput, + ) => { + inscriptionsFromPayment.push(...item.inscriptions); + satributesFromPayment.push(...item.satributes); + }, + ); + + const showSendBtcAmount = netBtcAmount < 0; + + // if transaction is not final, then runes will be delegated and will show up in the delegation section + const showRuneTransfers = transactionIsFinal && (runeSummary?.transfers ?? []).length > 0; + + const hasInscriptionsRareSatsInOrdinal = + (!transactionIsFinal && inputFromOrdinal.length > 0) || outputsFromOrdinal.length > 0; + + /* Receive section */ + + const showReceiveBtcAmount = netBtcAmount > 0; + const { outputsToPayment, outputsToOrdinal } = getOutputsWithAssetsToUserAddress({ + outputs: summary?.outputs, + btcAddress, + ordinalsAddress, + }); + + // if receiving runes from own addresses, hide it because it is change, unless it swap addresses (recover runes) + const filteredRuneReceipts: RuneSummary['receipts'] = + runeSummary?.receipts?.filter( + (receipt) => + !receipt.sourceAddresses.some( + (address) => + (address === ordinalsAddress && receipt.destinationAddress === ordinalsAddress) || + (address === btcAddress && receipt.destinationAddress === btcAddress), + ), + ) ?? []; + const ordinalRuneReceipts = filteredRuneReceipts.filter( + (receipt) => receipt.destinationAddress === ordinalsAddress, + ); + const paymentRuneReceipts = filteredRuneReceipts.filter( + (receipt) => receipt.destinationAddress === btcAddress, + ); + + const inscriptionsRareSatsInPayment = outputsToPayment.filter( + (output) => + output.inscriptions.length > 0 || (hasActivatedRareSatsKey && output.satributes.length > 0), + ); + + // if transaction is not final, then runes will be delegated and will show up in the delegation section + const showOrdinalRunes = !!(transactionIsFinal && ordinalRuneReceipts.length); + + // if transaction is not final, then runes will be delegated and will show up in the delegation section + const showPaymentRunes = !!(transactionIsFinal && paymentRuneReceipts.length); + + return { + summary, + runeSummary, + hasExternalInputs, + hasInsufficientRunes, + hasOutputScript, + hasSigHashNone, + hasSigHashSingle, + isUnconfirmedInput, + netBtcAmount, + showCenotaphCallout, + transactionIsFinal, + validMintingRune, + sendSection: { + showSendSection: showSendBtcAmount || showRuneTransfers || hasInscriptionsRareSatsInOrdinal, + showBtcAmount: showSendBtcAmount, + showRuneTransfers, + hasInscriptionsRareSatsInOrdinal, + outputsFromOrdinal, + inputFromOrdinal, + inscriptionsFromPayment, + satributesFromPayment, + }, + receiveSection: { + showOrdinalSection: showOrdinalRunes || outputsToOrdinal.length > 0, + showPaymentSection: + showReceiveBtcAmount || showPaymentRunes || inscriptionsRareSatsInPayment.length > 0, + showBtcAmount: showReceiveBtcAmount, + inscriptionsRareSatsInPayment, + ordinalRuneReceipts, + outputsToOrdinal, + outputsToPayment, + paymentRuneReceipts, + showOrdinalRunes, + showPaymentRunes, + }, + }; +}; diff --git a/src/app/components/confirmBtcTransaction/index.tsx b/src/app/components/confirmBtcTransaction/index.tsx index 3f595643c..d7bbf9075 100644 --- a/src/app/components/confirmBtcTransaction/index.tsx +++ b/src/app/components/confirmBtcTransaction/index.tsx @@ -1,25 +1,25 @@ import { delay } from '@common/utils/ledger'; +import { + ParsedTxSummaryContext, + type ParsedTxSummaryContextProps, + useParsedTxSummaryContext, +} from '@components/confirmBtcTransaction/hooks/useParsedTxSummaryContext'; +import TransactionSummary from '@components/confirmBtcTransaction/transactionSummary'; import type { Tab } from '@components/tabBar'; import useSelectedAccount from '@hooks/useSelectedAccount'; import TransportFactory from '@ledgerhq/hw-transport-webusb'; -import type { - RuneSummary, - RuneSummaryActions, - Transport, - btcTransaction, -} from '@secretkeylabs/xverse-core'; +import type { Transport } from '@secretkeylabs/xverse-core'; import Button from '@ui-library/button'; import Callout from '@ui-library/callout'; import { StickyHorizontalSplitButtonContainer, StyledP } from '@ui-library/common.styled'; import Sheet from '@ui-library/sheet'; import Spinner from '@ui-library/spinner'; import { isLedgerAccount } from '@utils/helper'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; import SendLayout from '../../layouts/sendLayout'; import LedgerStepView, { Steps } from './ledgerStepView'; -import TransactionSummary from './transactionSummary'; const LoaderContainer = styled.div(() => ({ display: 'flex', @@ -49,11 +49,8 @@ const SuccessActionsContainer = styled.div((props) => ({ })); type Props = { - inputs: btcTransaction.EnhancedInput[]; - outputs: btcTransaction.EnhancedOutput[]; - feeOutput?: btcTransaction.TransactionFeeOutput; - runeSummary?: RuneSummaryActions | RuneSummary; - showCenotaphCallout: boolean; + summary?: ParsedTxSummaryContextProps['summary']; + runeSummary?: ParsedTxSummaryContextProps['runeSummary']; isLoading: boolean; isSubmitting: boolean; isBroadcast?: boolean; @@ -72,20 +69,13 @@ type Props = { ) => Promise; onFeeRateSet?: (feeRate: number) => void; feeRate?: number; - isFinal?: boolean; - // TODO: add sighash single warning - hasSigHashSingle?: boolean; - hasSigHashNone?: boolean; title?: string; selectedBottomTab?: Tab; }; function ConfirmBtcTransaction({ - inputs, - outputs, - feeOutput, + summary, runeSummary, - showCenotaphCallout, isLoading, isSubmitting, isBroadcast, @@ -101,18 +91,20 @@ function ConfirmBtcTransaction({ getFeeForFeeRate, onFeeRateSet, feeRate, - hasSigHashNone = false, - isFinal = true, - hasSigHashSingle = false, title, selectedBottomTab, }: Props) { + const parsedTxSummaryContextValue = useMemo( + () => ({ summary, runeSummary }), + [summary, runeSummary], + ); const [isModalVisible, setIsModalVisible] = useState(false); const [currentStep, setCurrentStep] = useState(Steps.ConnectLedger); const [isButtonDisabled, setIsButtonDisabled] = useState(false); const [isConnectSuccess, setIsConnectSuccess] = useState(false); const [isConnectFailed, setIsConnectFailed] = useState(false); const [isTxRejected, setIsTxRejected] = useState(false); + const { hasInsufficientRunes, hasSigHashNone, validMintingRune } = useParsedTxSummaryContext(); const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' }); const { t: signatureRequestTranslate } = useTranslation('translation', { @@ -121,17 +113,11 @@ function ConfirmBtcTransaction({ const selectedAccount = useSelectedAccount(); const hideBackButton = !onBackClick; - const hasInsufficientRunes = - runeSummary?.transfers?.some((transfer) => !transfer.hasSufficientBalance) ?? false; - const validMintingRune = - !runeSummary?.mint || - (runeSummary?.mint && runeSummary.mint.runeIsOpen && runeSummary.mint.runeIsMintable); const onConfirmPress = async () => { if (!isLedgerAccount(selectedAccount)) { return onConfirm(); } - // show ledger connection screens setIsModalVisible(true); }; @@ -189,7 +175,8 @@ function ConfirmBtcTransaction({ ) : ( - <> + + {/* TODO start a new layout. SendLayout was not intended for the review screens */} {title || t('REVIEW_TRANSACTION')} + {/* TODO: add sighash single warning */} {hasSigHashNone && ( } - + ); } diff --git a/src/app/components/confirmBtcTransaction/mintSection.tsx b/src/app/components/confirmBtcTransaction/mintSection.tsx index ff2ebbcb5..2789261ad 100644 --- a/src/app/components/confirmBtcTransaction/mintSection.tsx +++ b/src/app/components/confirmBtcTransaction/mintSection.tsx @@ -21,7 +21,7 @@ import { } from './runes'; type Props = { - mints?: RuneSummary['mint'][] | MintActionDetails[]; + mints?: RuneSummary['mint'][] | MintActionDetails[] | undefined[]; }; function MintSection({ mints }: Props) { @@ -29,21 +29,27 @@ function MintSection({ mints }: Props) { const { ordinalsAddress } = useSelectedAccount(); if (!mints) return null; + const isMintActionDetails = ( + obj: RuneSummary['mint'] | MintActionDetails | undefined, + ): obj is MintActionDetails => obj !== undefined && 'repeats' in obj; + return ( <> {mints.map( - (mint) => + (mint: RuneSummary['mint'] | MintActionDetails | undefined) => mint && (
{t('YOU_WILL_MINT')} - {mint.repeats && {`x${mint.repeats}`}} + {isMintActionDetails(mint) && mint.repeats && {`x${mint.repeats}`}} - {mint.destinationAddress && mint.destinationAddress !== ordinalsAddress + {isMintActionDetails(mint) && + mint.destinationAddress && + mint.destinationAddress !== ordinalsAddress ? getShortTruncatedAddress(mint.destinationAddress) : t('YOUR_ORDINAL_ADDRESS')} @@ -51,42 +57,42 @@ function MintSection({ mints }: Props) {
- {mint?.runeName} + {mint.runeName}
- {mint?.symbol} + {mint.symbol} {t('AMOUNT')} - {mint.runeSize && ( // This is the only place where runeSize is used + {isMintActionDetails(mint) && mint.runeSize && ( {t('RUNE_SIZE')}: {mint.runeSize} Sats )} - ( - + + ( {value} - - {mint?.symbol} - - - )} - /> + )} + /> + + {mint.symbol} + +
), diff --git a/src/app/components/confirmBtcTransaction/receiveSection.tsx b/src/app/components/confirmBtcTransaction/receiveSection.tsx index dd9c27bac..f14e72699 100644 --- a/src/app/components/confirmBtcTransaction/receiveSection.tsx +++ b/src/app/components/confirmBtcTransaction/receiveSection.tsx @@ -1,8 +1,7 @@ +import { useParsedTxSummaryContext } from '@components/confirmBtcTransaction/hooks/useParsedTxSummaryContext'; import RuneAmount from '@components/confirmBtcTransaction/itemRow/runeAmount'; -import useSelectedAccount from '@hooks/useSelectedAccount'; -import useWalletSelector from '@hooks/useWalletSelector'; import { ArrowRight } from '@phosphor-icons/react'; -import type { btcTransaction, RuneSummary } from '@secretkeylabs/xverse-core'; +import type { btcTransaction } from '@secretkeylabs/xverse-core'; import { StyledP } from '@ui-library/common.styled'; import { useTranslation } from 'react-i18next'; import { NumericFormat } from 'react-number-format'; @@ -10,7 +9,6 @@ import styled from 'styled-components'; import Theme from 'theme'; import Amount from './itemRow/amount'; import InscriptionSatributeRow from './itemRow/inscriptionSatributeRow'; -import { getOutputsWithAssetsToUserAddress } from './utils'; const Container = styled.div((props) => ({ display: 'flex', @@ -51,64 +49,40 @@ const BundleHeader = styled.div((props) => ({ })); type Props = { - outputs: btcTransaction.EnhancedOutput[]; - hasExternalInputs: boolean; - netAmount: number; - transactionIsFinal: boolean; onShowInscription: (inscription: btcTransaction.IOInscription) => void; - runeReceipts?: RuneSummary['receipts']; }; -function ReceiveSection({ - outputs, - hasExternalInputs, - netAmount, - onShowInscription, - runeReceipts, - transactionIsFinal, -}: Props) { - const { btcAddress, ordinalsAddress } = useSelectedAccount(); - const { hasActivatedRareSatsKey } = useWalletSelector(); +function ReceiveSection({ onShowInscription }: Props) { const { t } = useTranslation('translation'); + const { + hasExternalInputs, + netBtcAmount, + receiveSection: { + showOrdinalSection, + showPaymentSection, + showBtcAmount, + inscriptionsRareSatsInPayment, + ordinalRuneReceipts, + outputsToOrdinal, + paymentRuneReceipts, + showOrdinalRunes, + showPaymentRunes, + }, + } = useParsedTxSummaryContext(); - const { outputsToPayment, outputsToOrdinal } = getOutputsWithAssetsToUserAddress({ - outputs, - btcAddress, - ordinalsAddress, - }); + /** TODO - start bundling send/receive data by output addresses + * switch(output.type) === 'address' -> display `destinationAddress` + * script -> OP_RETURN + * ms -> nothing + * Each address can have 1:N bundles + * Challenge right now is how to to group the btc, runes, inscriptions data together by output address + */ - // if receiving runes from own addresses, hide it because it is change, unless it swap addresses (recover runes) - const filteredRuneReceipts = - runeReceipts?.filter( - (receipt) => - !receipt.sourceAddresses.some( - (address) => - (address === ordinalsAddress && receipt.destinationAddress === ordinalsAddress) || - (address === btcAddress && receipt.destinationAddress === btcAddress), - ), - ) ?? []; - const ordinalRuneReceipts = filteredRuneReceipts.filter( - (receipt) => receipt.destinationAddress === ordinalsAddress, - ); - const paymentRuneReceipts = filteredRuneReceipts.filter( - (receipt) => receipt.destinationAddress === btcAddress, - ); - - const inscriptionsRareSatsInPayment = outputsToPayment.filter( - (output) => - output.inscriptions.length > 0 || (hasActivatedRareSatsKey && output.satributes.length > 0), - ); - const areInscriptionsRareSatsInPayment = inscriptionsRareSatsInPayment.length > 0; - const areInscriptionRareSatsInOrdinal = outputsToOrdinal.length > 0; - const amountIsBiggerThanZero = netAmount > 0; - - // if transaction is not final, then runes will be delegated and will show up in the delegation section - const showOrdinalRunes = !!(transactionIsFinal && ordinalRuneReceipts.length); - const showOrdinalSection = showOrdinalRunes || areInscriptionRareSatsInOrdinal; - - // if transaction is not final, then runes will be delegated and will show up in the delegation section - const showPaymentRunes = !!(transactionIsFinal && paymentRuneReceipts.length); - const showPaymentSection = - amountIsBiggerThanZero || showPaymentRunes || areInscriptionsRareSatsInPayment; + /** Receive Data + * BTC: netBtcAmount + * Runes: paymentRuneReceipts + * Ordinals: ordinalRuneReceipts & outputsToOrdinal + * Rare Sats: inscriptionsRareSatsInPayment + */ return ( <> @@ -154,7 +128,7 @@ function ReceiveSection({ ))} - {areInscriptionRareSatsInOrdinal && ( + {outputsToOrdinal.length > 0 && ( {outputsToOrdinal .sort((a, b) => b.inscriptions.length - a.inscriptions.length) @@ -194,16 +168,13 @@ function ReceiveSection({ ))} - {amountIsBiggerThanZero && ( + {showBtcAmount && ( - + )} - {areInscriptionsRareSatsInPayment && ( - + {inscriptionsRareSatsInPayment.length > 0 && ( + {inscriptionsRareSatsInPayment .sort((a, b) => b.inscriptions.length - a.inscriptions.length) .map((output, index) => ( @@ -216,7 +187,7 @@ function ReceiveSection({ amount={output.amount} onShowInscription={onShowInscription} showTopDivider={ - (Boolean(paymentRuneReceipts.length) || amountIsBiggerThanZero) && index === 0 + (Boolean(paymentRuneReceipts.length) || showBtcAmount) && index === 0 } showBottomDivider={inscriptionsRareSatsInPayment.length > index + 1} /> diff --git a/src/app/components/confirmBtcTransaction/sendSection.tsx b/src/app/components/confirmBtcTransaction/sendSection.tsx new file mode 100644 index 000000000..10243caa9 --- /dev/null +++ b/src/app/components/confirmBtcTransaction/sendSection.tsx @@ -0,0 +1,161 @@ +import { useParsedTxSummaryContext } from '@components/confirmBtcTransaction/hooks/useParsedTxSummaryContext'; +import RuneAmount from '@components/confirmBtcTransaction/itemRow/runeAmount'; +import type { btcTransaction } from '@secretkeylabs/xverse-core'; +import { StyledP } from '@ui-library/common.styled'; +import Divider from '@ui-library/divider'; +import { useTranslation } from 'react-i18next'; +import { NumericFormat } from 'react-number-format'; +import styled from 'styled-components'; +import Amount from './itemRow/amount'; +import AmountWithInscriptionSatribute from './itemRow/amountWithInscriptionSatribute'; +import InscriptionSatributeRow from './itemRow/inscriptionSatributeRow'; + +const Title = styled.p` + ${(props) => props.theme.typography.body_medium_m}; + color: ${(props) => props.theme.colors.white_200}; + margin-top: ${(props) => props.theme.space.s}; + margin-bottom: ${(props) => props.theme.space.xs}; +`; + +const Container = styled.div((props) => ({ + display: 'flex', + flexDirection: 'column', + background: props.theme.colors.elevation1, + borderRadius: props.theme.radius(2), + padding: `${props.theme.space.m} 0 20px`, + justifyContent: 'center', + marginBottom: props.theme.space.s, +})); + +const RowContainer = styled.div<{ noPadding?: boolean; noMargin?: boolean }>((props) => ({ + padding: props.noPadding ? 0 : `0 ${props.theme.space.m}`, +})); + +const BundleHeader = styled.div((props) => ({ + display: 'flex', + flex: 1, + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: props.theme.space.m, +})); + +function SendSection({ + onShowInscription, +}: { + onShowInscription: (inscription: btcTransaction.IOInscription) => void; +}) { + const { t } = useTranslation('translation'); + const { + runeSummary, + netBtcAmount, + transactionIsFinal, + sendSection: { + showSendSection, + showBtcAmount, + showRuneTransfers, + hasInscriptionsRareSatsInOrdinal, + outputsFromOrdinal, + inputFromOrdinal, + inscriptionsFromPayment, + satributesFromPayment, + }, + } = useParsedTxSummaryContext(); + + /** TODO - start bundling send/receive data by output addresses + * switch(output.type) === 'address' -> display `destinationAddress` + * script -> OP_RETURN + * ms -> nothing + * Each address can have 1:N bundles + * Challenge right now is how to to group the btc, runes, inscriptions data together by output address + */ + + /** Send Data + * BTC: netBtcAmount + * Runes: runeSummary.transfers + * Ordinals: outputsFromOrdinal OR inputFromOrdinal (depending if tx is final) + */ + + if (!showSendSection) return null; + + return ( + <> + {t('CONFIRM_TRANSACTION.YOU_WILL_SEND')} + + {showBtcAmount && ( + + + + + )} + {showRuneTransfers && + runeSummary?.transfers?.map((transfer, index) => ( + <> + {showBtcAmount && } + + +
+ + {t('COMMON.BUNDLE')} + +
+
+ ( + + {value} + + )} + /> +
+
+ +
+ {runeSummary?.transfers.length > index + 1 && } + + ))} + {hasInscriptionsRareSatsInOrdinal && ( + + {transactionIsFinal + ? outputsFromOrdinal.map((output, index) => ( + index + 1} + /> + )) + : inputFromOrdinal.map((input, index) => ( + index + 1} + /> + ))} + + )} +
+ + ); +} + +export default SendSection; diff --git a/src/app/components/confirmBtcTransaction/transactionSummary.tsx b/src/app/components/confirmBtcTransaction/transactionSummary.tsx index 183692a67..df5f3c811 100644 --- a/src/app/components/confirmBtcTransaction/transactionSummary.tsx +++ b/src/app/components/confirmBtcTransaction/transactionSummary.tsx @@ -7,11 +7,9 @@ import MintSection from '@components/confirmBtcTransaction/mintSection'; import TransferFeeView from '@components/transferFeeView'; import useCoinRates from '@hooks/queries/useCoinRates'; import useBtcFeeRate from '@hooks/useBtcFeeRate'; -import useSelectedAccount from '@hooks/useSelectedAccount'; import { btcTransaction, getBtcFiatEquivalent, - type RuneSummary, type RuneSummaryActions, } from '@secretkeylabs/xverse-core'; import SelectFeeRate from '@ui-components/selectFeeRate'; @@ -22,11 +20,12 @@ import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; import DelegateSection from './delegateSection'; import EtchSection from './etchSection'; +import { useParsedTxSummaryContext } from './hooks/useParsedTxSummaryContext'; import AmountWithInscriptionSatribute from './itemRow/amountWithInscriptionSatribute'; import ReceiveSection from './receiveSection'; +import SendSection from './sendSection'; import TransferSection from './transferSection'; import TxInOutput from './txInOutput/txInOutput'; -import { getNetAmount, isScriptOutput } from './utils'; const Container = styled.div((props) => ({ background: props.theme.colors.elevation1, @@ -48,12 +47,6 @@ const Subtitle = styled.p` `; type Props = { - transactionIsFinal: boolean; - showCenotaphCallout: boolean; - inputs: btcTransaction.EnhancedInput[]; - outputs: btcTransaction.EnhancedOutput[]; - feeOutput?: btcTransaction.TransactionFeeOutput; - runeSummary?: RuneSummaryActions | RuneSummary; getFeeForFeeRate?: ( feeRate: number, useEffectiveFeeRate?: boolean, @@ -63,18 +56,7 @@ type Props = { isSubmitting?: boolean; }; -function TransactionSummary({ - transactionIsFinal, - showCenotaphCallout, - inputs, - outputs, - feeOutput, - runeSummary, - isSubmitting, - getFeeForFeeRate, - onFeeRateSet, - feeRate, -}: Props) { +function TransactionSummary({ isSubmitting, getFeeForFeeRate, onFeeRateSet, feeRate }: Props) { const [inscriptionToShow, setInscriptionToShow] = useState< btcTransaction.IOInscription | undefined >(undefined); @@ -83,28 +65,16 @@ function TransactionSummary({ const { network, fiatCurrency } = useWalletSelector(); const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' }); const { t: tUnits } = useTranslation('translation', { keyPrefix: 'UNITS' }); - - const { btcAddress, ordinalsAddress } = useSelectedAccount(); const { data: recommendedFees } = useBtcFeeRate(); - - const hasOutputScript = outputs.some((output) => isScriptOutput(output)); - - // TODO - move logic into core - const hasExternalInputs = inputs.some( - (input) => - input.extendedUtxo.address !== btcAddress && input.extendedUtxo.address !== ordinalsAddress, - ); - - const netAmount = getNetAmount({ - inputs, - outputs, - btcAddress, - ordinalsAddress, - }); - - const isUnConfirmedInput = inputs.some( - (input) => !input.extendedUtxo.utxo.status.confirmed && input.walletWillSign, - ); + const { + summary, + runeSummary, + hasExternalInputs, + isUnconfirmedInput, + hasOutputScript, + showCenotaphCallout, + transactionIsFinal, + } = useParsedTxSummaryContext(); const satsToFiat = (sats: string) => getBtcFiatEquivalent(new BigNumber(sats), new BigNumber(btcFiatRate)).toString(); @@ -125,7 +95,7 @@ function TransactionSummary({ }} /> )} - {isUnConfirmedInput && ( + {isUnconfirmedInput && ( )} {showCenotaphCallout && ( @@ -138,23 +108,12 @@ function TransactionSummary({ )} {hasRuneDelegation && } - - + {hasExternalInputs ? ( + + ) : ( + + )} + {!hasRuneDelegation && } @@ -162,45 +121,49 @@ function TransactionSummary({ {t('TRANSACTION_DETAILS')} - + {hasOutputScript && !runeSummary && } - {feeOutput && !showFeeSelector && ( + {summary?.feeOutput && !showFeeSelector && ( )} - {feeOutput && showFeeSelector && ( - <> - {t('FEES')} - - onFeeRateSet(+newFeeRate)} - baseToFiat={satsToFiat} - fiatUnit={fiatCurrency} - getFeeForFeeRate={getFeeForFeeRate} - feeRates={{ - medium: recommendedFees?.regular, - high: recommendedFees?.priority, - }} - feeRateLimits={recommendedFees?.limits} - isLoading={isSubmitting} - /> - - - - )} + {summary?.feeOutput && + showFeeSelector && + onFeeRateSet && + getFeeForFeeRate && + recommendedFees && ( + <> + {t('FEES')} + + onFeeRateSet(+newFeeRate)} + baseToFiat={satsToFiat} + fiatUnit={fiatCurrency} + getFeeForFeeRate={getFeeForFeeRate} + feeRates={{ + medium: recommendedFees.regular, + high: recommendedFees.priority, + }} + feeRateLimits={recommendedFees.limits} + isLoading={isSubmitting} + /> + + + + )} ); } diff --git a/src/app/components/confirmBtcTransaction/transferSection.tsx b/src/app/components/confirmBtcTransaction/transferSection.tsx index e06c8031d..78778b0f1 100644 --- a/src/app/components/confirmBtcTransaction/transferSection.tsx +++ b/src/app/components/confirmBtcTransaction/transferSection.tsx @@ -1,17 +1,15 @@ +import { useParsedTxSummaryContext } from '@components/confirmBtcTransaction/hooks/useParsedTxSummaryContext'; import RuneAmount from '@components/confirmBtcTransaction/itemRow/runeAmount'; -import useSelectedAccount from '@hooks/useSelectedAccount'; import { WarningOctagon } from '@phosphor-icons/react'; -import { type RuneSummary, btcTransaction } from '@secretkeylabs/xverse-core'; +import { type btcTransaction } from '@secretkeylabs/xverse-core'; import { StyledP } from '@ui-library/common.styled'; import Divider from '@ui-library/divider'; import { useTranslation } from 'react-i18next'; -import { NumericFormat } from 'react-number-format'; import styled from 'styled-components'; import Theme from 'theme'; import Amount from './itemRow/amount'; import AmountWithInscriptionSatribute from './itemRow/amountWithInscriptionSatribute'; import InscriptionSatributeRow from './itemRow/inscriptionSatributeRow'; -import { getInputsWitAssetsFromUserAddress, getOutputsWithAssetsFromUserAddress } from './utils'; const Title = styled.p` ${(props) => props.theme.typography.body_medium_m}; @@ -46,143 +44,71 @@ const WarningText = styled(StyledP)` flex: 1 0 0; `; -const BundleHeader = styled.div((props) => ({ - display: 'flex', - flex: 1, - flexDirection: 'row', - justifyContent: 'space-between', - marginBottom: props.theme.space.m, -})); +type Props = { onShowInscription: (inscription: btcTransaction.IOInscription) => void }; -type Props = { - outputs: btcTransaction.EnhancedOutput[]; - inputs: btcTransaction.EnhancedInput[]; - hasExternalInputs: boolean; - transactionIsFinal: boolean; - runeTransfers?: RuneSummary['transfers']; - netAmount: number; - onShowInscription: (inscription: btcTransaction.IOInscription) => void; -}; - -// if isPartialTransaction, we use inputs instead of outputs -function TransferSection({ - outputs, - inputs, - hasExternalInputs, - transactionIsFinal, - runeTransfers, - netAmount, - onShowInscription, -}: Props) { +function TransferSection({ onShowInscription }: Props) { const { t } = useTranslation('translation'); - const { btcAddress, ordinalsAddress } = useSelectedAccount(); - - const { inputFromPayment, inputFromOrdinal } = getInputsWitAssetsFromUserAddress({ - inputs, - btcAddress, - ordinalsAddress, - }); - const { outputsFromPayment, outputsFromOrdinal } = getOutputsWithAssetsFromUserAddress({ - outputs, - btcAddress, - ordinalsAddress, - }); + const { + runeSummary, + netBtcAmount, + transactionIsFinal, + sendSection: { + showSendSection, + showBtcAmount, + showRuneTransfers, + hasInscriptionsRareSatsInOrdinal, + outputsFromOrdinal, + inputFromOrdinal, + inscriptionsFromPayment, + satributesFromPayment, + }, + } = useParsedTxSummaryContext(); - const showAmount = netAmount > 0; - - const inscriptionsFromPayment: btcTransaction.IOInscription[] = []; - const satributesFromPayment: btcTransaction.IOSatribute[] = []; - (transactionIsFinal ? outputsFromPayment : inputFromPayment).forEach((item) => { - inscriptionsFromPayment.push(...item.inscriptions); - satributesFromPayment.push(...item.satributes); - }); - // if transaction is not final, then runes will be delegated and will show up in the delegation section - const hasRuneTransfers = transactionIsFinal && (runeTransfers ?? []).length > 0; - const hasInscriptionsRareSatsInOrdinal = - (!transactionIsFinal && inputFromOrdinal.length > 0) || outputsFromOrdinal.length > 0; - - const hasData = showAmount || hasRuneTransfers || hasInscriptionsRareSatsInOrdinal; - - if (!hasData) return null; + if (!showSendSection) return null; return ( <> - - {hasExternalInputs - ? t('CONFIRM_TRANSACTION.YOU_WILL_TRANSFER') - : t('CONFIRM_TRANSACTION.YOU_WILL_SEND')} - + {t('CONFIRM_TRANSACTION.YOU_WILL_TRANSFER')} - {showAmount && ( + {showBtcAmount && ( - + - {hasExternalInputs && ( - - - - {t('CONFIRM_TRANSACTION.BTC_TRANSFER_WARNING')} - - - )} + + + + {t('CONFIRM_TRANSACTION.BTC_TRANSFER_WARNING')} + + )} - { - // if transaction is not final, then runes will be delegated and will show up in the delegation section - transactionIsFinal && - runeTransfers?.map((transfer, index) => ( - <> - {showAmount && index === 0 && } - - {!hasExternalInputs && ( - -
- - {t('COMMON.BUNDLE')} - -
-
- ( - - {value} - - )} - /> -
-
- )} - -
- {runeTransfers.length > index + 1 && } - - )) - } + {showRuneTransfers && + runeSummary?.transfers?.map((transfer, index) => ( + <> + {showBtcAmount && } + + + + {runeSummary?.transfers.length > index + 1 && } + + ))} {hasInscriptionsRareSatsInOrdinal && ( - + {!transactionIsFinal ? inputFromOrdinal.map((input, index) => ( index + 1} /> )) @@ -190,12 +116,12 @@ function TransferSection({ index + 1} /> ))} diff --git a/src/app/components/confirmBtcTransaction/txInOutput/txInOutput.tsx b/src/app/components/confirmBtcTransaction/txInOutput/txInOutput.tsx index 7a9e11e9f..ee2201638 100644 --- a/src/app/components/confirmBtcTransaction/txInOutput/txInOutput.tsx +++ b/src/app/components/confirmBtcTransaction/txInOutput/txInOutput.tsx @@ -1,6 +1,6 @@ import DropDownIcon from '@assets/img/transactions/dropDownIcon.svg'; +import { useParsedTxSummaryContext } from '@components/confirmBtcTransaction/hooks/useParsedTxSummaryContext'; import { animated, config, useSpring } from '@react-spring/web'; -import { btcTransaction } from '@secretkeylabs/xverse-core'; import { StyledP } from '@ui-library/common.styled'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -42,15 +42,11 @@ const ExpandedContainer = styled(animated.div)((props) => ({ marginTop: props.theme.space.m, })); -type Props = { - inputs: btcTransaction.EnhancedInput[]; - outputs: btcTransaction.EnhancedOutput[]; -}; - -function TxInOutput({ inputs, outputs }: Props) { +function TxInOutput() { const [isExpanded, setIsExpanded] = useState(false); const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' }); + const { summary } = useParsedTxSummaryContext(); const slideInStyles = useSpring({ config: { ...config.gentle, duration: 400 }, @@ -80,13 +76,13 @@ function TxInOutput({ inputs, outputs }: Props) { {isExpanded && ( - {inputs.map((input) => ( + {summary?.inputs.map((input) => ( ))} {t('OUTPUT')} - {outputs.map((output, index) => ( + {summary?.outputs.map((output, index) => ( { + if (!inputs || !outputs) { + return 0; + } + const initialValue = 0; const totalUserSpend = inputs.reduce((accumulator: number, input) => { @@ -67,7 +73,10 @@ export const getOutputsWithAssetsFromUserAddress = ({ btcAddress, ordinalsAddress, outputs, -}: Omit) => { +}: Omit): { + outputsFromPayment: (btcTransaction.TransactionOutput | btcTransaction.TransactionPubKeyOutput)[]; + outputsFromOrdinal: (btcTransaction.TransactionOutput | btcTransaction.TransactionPubKeyOutput)[]; +} => { // we want to discard outputs that are script, are not from user address and do not have inscriptions or satributes const outputsFromPayment: ( | btcTransaction.TransactionOutput @@ -77,7 +86,7 @@ export const getOutputsWithAssetsFromUserAddress = ({ | btcTransaction.TransactionOutput | btcTransaction.TransactionPubKeyOutput )[] = []; - outputs.forEach((output) => { + outputs?.forEach((output) => { if (isScriptOutput(output)) { return; } @@ -104,15 +113,18 @@ export const getOutputsWithAssetsFromUserAddress = ({ return { outputsFromPayment, outputsFromOrdinal }; }; -export const getInputsWitAssetsFromUserAddress = ({ +export const getInputsWithAssetsFromUserAddress = ({ btcAddress, ordinalsAddress, inputs, -}: Omit) => { +}: Omit): { + inputFromPayment: btcTransaction.EnhancedInput[]; + inputFromOrdinal: btcTransaction.EnhancedInput[]; +} => { // we want to discard inputs that are not from user address and do not have inscriptions or satributes const inputFromPayment: btcTransaction.EnhancedInput[] = []; const inputFromOrdinal: btcTransaction.EnhancedInput[] = []; - inputs.forEach((input) => { + inputs?.forEach((input) => { if (!input.inscriptions.length && !input.satributes.length) { return; } @@ -132,10 +144,13 @@ export const getOutputsWithAssetsToUserAddress = ({ btcAddress, ordinalsAddress, outputs, -}: Omit) => { +}: Omit): { + outputsToPayment: btcTransaction.TransactionOutput[]; + outputsToOrdinal: btcTransaction.TransactionOutput[]; +} => { const outputsToPayment: btcTransaction.TransactionOutput[] = []; const outputsToOrdinal: btcTransaction.TransactionOutput[] = []; - outputs.forEach((output) => { + outputs?.forEach((output) => { // we want to discard outputs that are not spendable or are not to user address if ( isScriptOutput(output) || diff --git a/src/app/routes/index.tsx b/src/app/routes/index.tsx index cb14b8b83..d2c541f4a 100644 --- a/src/app/routes/index.tsx +++ b/src/app/routes/index.tsx @@ -478,6 +478,7 @@ const router = createHashRouter([ ), }, + // TODO can we move this into extended screen container? { path: RequestsRoutes.MintRune, element: ( @@ -486,6 +487,7 @@ const router = createHashRouter([ ), }, + // TODO can we move this into extended screen container? { path: RequestsRoutes.EtchRune, element: ( @@ -509,6 +511,7 @@ const router = createHashRouter([ path: 'send-stx', element: , }, + // TODO can we kill this one? { path: 'confirm-btc-tx', element: , diff --git a/src/app/screens/confirmOrdinalTransaction/SendInscriptionsRequest.tsx b/src/app/screens/confirmOrdinalTransaction/SendInscriptionsRequest.tsx index 1e34a7895..9f7f14488 100644 --- a/src/app/screens/confirmOrdinalTransaction/SendInscriptionsRequest.tsx +++ b/src/app/screens/confirmOrdinalTransaction/SendInscriptionsRequest.tsx @@ -27,6 +27,7 @@ function SendInscriptionsRequest() { isLoading, transaction, summary, + runeSummary, txError, } = useSendInscriptions(); @@ -59,11 +60,9 @@ function SendInscriptionsRequest() { )} {transaction && summary && !txError && ( { const [feeRate, setFeeRate] = useState(''); const [transaction, setTransaction] = useState(); const [summary, setSummary] = useState(); + const [runeSummary, setRuneSummary] = useState(); + const [isExecuting, setIsExecuting] = useState(false); const [isLoading, setIsLoading] = useState(false); + const hasRunesSupport = useHasFeature(FeatureId.RUNES_SUPPORT); const { popupPayloadSendInscriptions: { context: { tabId }, @@ -63,6 +73,9 @@ const useSendInscriptions = () => { setFeeRate(desiredFeeRate.toString()); setTransaction(tx); setSummary(txSummary); + if (hasRunesSupport) { + setRuneSummary(await parseSummaryForRunes(txContext, txSummary, txContext.network)); + } } catch (e) { setTransaction(undefined); setSummary(undefined); @@ -131,6 +144,7 @@ const useSendInscriptions = () => { return { transaction, summary, + runeSummary, txError, feeRate, isLoading, diff --git a/src/app/screens/etchRune/index.tsx b/src/app/screens/etchRune/index.tsx index a6b9460f0..49b9148b0 100644 --- a/src/app/screens/etchRune/index.tsx +++ b/src/app/screens/etchRune/index.tsx @@ -64,17 +64,7 @@ function EtchRune() { )} {orderTx && orderTx.summary && !etchError && ( { const payloadToken = params.get('payload') ?? ''; const payload = SuperJSON.parse>(payloadToken); - const mintRequest = { ...payload, network: undefined }; + const mintRequest = { ...payload }; return { mintRequest, tabId, requestId, network: payload.network }; }; -const useMintRequest = () => { +const useMintRequest = (): { + runeInfo: Rune | null; + mintRequest: MintRunesParams; + orderTx: TransactionBuildPayload | null; + mintError: { code: number | undefined; message: string } | null; + feeRate: string; + isExecuting: boolean; + handleMint: () => Promise; + payAndConfirmMintRequest: (ledgerTransport?: Transport) => Promise; + cancelMintRequest: () => Promise; +} => { const { mintRequest, requestId, tabId } = useRuneMintRequestParams(); const txContext = useTransactionContext(); const ordinalsServiceApi = useOrdinalsServiceApi(); diff --git a/src/app/screens/restoreFunds/recoverRunes/index.tsx b/src/app/screens/restoreFunds/recoverRunes/index.tsx index 8e3b962c3..b8df3dacd 100644 --- a/src/app/screens/restoreFunds/recoverRunes/index.tsx +++ b/src/app/screens/restoreFunds/recoverRunes/index.tsx @@ -6,6 +6,7 @@ import useBtcFeeRate from '@hooks/useBtcFeeRate'; import useTransactionContext from '@hooks/useTransactionContext'; import type { TransactionSummary } from '@screens/sendBtc/helpers'; import { + btcTransaction, parseSummaryForRunes, runesTransaction, type RuneSummary, @@ -60,8 +61,7 @@ const ButtonContainer = styled.div((props) => ({ padding: `0 ${props.theme.space.m}`, })); -// TODO: export this from core -type EnhancedTransaction = Awaited>; +type EnhancedTransaction = btcTransaction.EnhancedTransaction; function RecoverRunes() { const { t } = useTranslation('translation', { keyPrefix: 'RECOVER_RUNES_SCREEN' }); @@ -171,12 +171,9 @@ function RecoverRunes() { ) : ( ( + location.state?.recipientAddress || '', + ); const [isLoading, setIsLoading] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [amountSats, setAmountSats] = useState(location.state?.amount || ''); @@ -46,9 +58,10 @@ function SendBtcScreen() { const [currentStep, setCurrentStep] = useState(initialStep); - const transactionContext = useTransactionContext(); const [transaction, setTransaction] = useState(); const [summary, setSummary] = useState(); + const [runeSummary, setRuneSummary] = useState(); + const hasRunesSupport = useHasFeature(FeatureId.RUNES_SUPPORT); useEffect(() => { if (!feeRate && btcFeeRate && !feeRatesLoading) { @@ -76,6 +89,7 @@ function SendBtcScreen() { if (!debouncedRecipient || !feeRate) { setTransaction(undefined); setSummary(undefined); + setRuneSummary(undefined); return; } @@ -86,13 +100,20 @@ function SendBtcScreen() { setIsLoading(true); try { const transactionDetails = await generateTransactionAndSummary(); - if (isCancelled.current) return; - setTransaction(transactionDetails.transaction); - - setSummary(transactionDetails.summary); - + if (transactionDetails.summary) { + setSummary(transactionDetails.summary); + if (hasRunesSupport) { + setRuneSummary( + await parseSummaryForRunes( + transactionContext, + transactionDetails.summary, + transactionContext.network, + ), + ); + } + } if (sendMax && transactionDetails.summary) { setAmountSats(transactionDetails.summary.outputs[0].amount.toString()); } @@ -103,7 +124,6 @@ function SendBtcScreen() { // don't log the error if it's just an insufficient funds error console.error(e); } - setTransaction(undefined); setSummary(undefined); } finally { @@ -192,6 +212,8 @@ function SendBtcScreen() { return ( ); } diff --git a/src/app/screens/sendBtc/stepDisplay.tsx b/src/app/screens/sendBtc/stepDisplay.tsx index e874819ba..00d7be006 100644 --- a/src/app/screens/sendBtc/stepDisplay.tsx +++ b/src/app/screens/sendBtc/stepDisplay.tsx @@ -1,5 +1,6 @@ import RecipientSelector from '@components/recipientSelector'; import TokenImage from '@components/tokenImage'; +import type { RuneSummary } from '@secretkeylabs/xverse-core'; import ConfirmBtcTransaction from 'app/components/confirmBtcTransaction'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; @@ -29,6 +30,7 @@ const Container = styled.div` type Props = { summary: TransactionSummary | undefined; + runeSummary: RuneSummary | undefined; currentStep: Step; setCurrentStep: (step: Step) => void; recipientAddress: string; @@ -51,6 +53,7 @@ type Props = { function StepDisplay({ summary, + runeSummary, currentStep, setCurrentStep, recipientAddress, @@ -104,9 +107,9 @@ function StepDisplay({ setFeeRate={setFeeRate} sendMax={sendMax} setSendMax={setSendMax} - fee={summary?.fee.toString()} + fee={(summary as TransactionSummary)?.fee.toString()} getFeeForFeeRate={getFeeForFeeRate} - dustFiltered={summary?.dustFiltered ?? false} + dustFiltered={(summary as TransactionSummary)?.dustFiltered ?? false} onNext={() => setCurrentStep(getNextStep(Step.SelectAmount, amountEditable))} hasSufficientFunds={!!summary || isLoading} isLoading={isLoading} @@ -121,10 +124,8 @@ function StepDisplay({ } return ( (Step.SelectRecipient); const [feeRate, setFeeRate] = useState(''); - const [recipientAddress, setRecipientAddress] = useState(location.state?.recipientAddress ?? ''); + const [recipientAddress, setRecipientAddress] = useState( + location.state?.recipientAddress ?? '', + ); const [isLoading, setIsLoading] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [transaction, setTransaction] = useState(); const [summary, setSummary] = useState(); + const [runeSummary, setRuneSummary] = useState(); const [insufficientFundsError, setInsufficientFundsError] = useState(false); + const hasRunesSupport = useHasFeature(FeatureId.RUNES_SUPPORT); useResetUserFlow(RoutePaths.SendOrdinal); @@ -49,6 +61,7 @@ function SendRuneScreen() { if (!recipientAddress || !feeRate) { setTransaction(undefined); setSummary(undefined); + setRuneSummary(undefined); setInsufficientFundsError(false); return; } @@ -66,6 +79,15 @@ function SendRuneScreen() { if (!transactionDetails) return; setTransaction(transactionDetails); setSummary(await transactionDetails.getSummary()); + if (hasRunesSupport) { + setRuneSummary( + await parseSummaryForRunes( + context, + await transactionDetails.getSummary(), + context.network, + ), + ); + } } catch (e) { if (e instanceof Error) { // don't log the error if it's just an insufficient funds error @@ -160,8 +182,9 @@ function SendRuneScreen() { return ( void; recipientAddress: string; @@ -48,8 +49,9 @@ type Props = { }; function StepDisplay({ - ordinal, summary, + runeSummary, + ordinal, currentStep, setCurrentStep, recipientAddress, @@ -65,6 +67,7 @@ function StepDisplay({ insufficientFunds, }: Props) { const { t } = useTranslation('translation'); + const header = ( } /> @@ -95,10 +98,8 @@ function StepDisplay({ } return ( ( + location.state?.recipientAddress || '', + ); const [amountError, setAmountError] = useState(''); const [amountToSend, setAmountToSend] = useState(location.state?.amount || ''); const [feeRate, setFeeRate] = useState(''); @@ -227,9 +230,9 @@ function SendRuneScreen() { return ( >; +type PsbtSummary = btcTransaction.PsbtSummary; +type ParsedPsbt = { summary: PsbtSummary; runeSummary: RuneSummary | undefined }; function SignBatchPsbtRequest() { const selectedAccount = useSelectedAccount(); @@ -80,13 +83,45 @@ function SignBatchPsbtRequest() { >(undefined); const hasRunesSupport = useHasFeature(FeatureId.RUNES_SUPPORT); useTrackMixPanelPageViewed(); + const [parsedPsbts, setParsedPsbts] = useState([]); + + const individualParsedTxSummaryContext = useMemo( + () => ({ + summary: parsedPsbts[currentPsbtIndex]?.summary, + runeSummary: parsedPsbts[currentPsbtIndex]?.runeSummary, + }), + [parsedPsbts, currentPsbtIndex], + ); - const [parsedPsbts, setParsedPsbts] = useState< - { summary: PsbtSummary; runeSummary: RuneSummary | undefined }[] - >([]); + const aggregatedParsedTxSummaryContext: ParsedTxSummaryContextProps = useMemo( + () => ({ + summary: { + inputs: parsedPsbts.map((psbt) => psbt.summary.inputs).flat(), + outputs: parsedPsbts.map((psbt) => psbt.summary.outputs).flat(), + feeOutput: undefined, + isFinal: parsedPsbts.reduce((acc, psbt) => acc && psbt.summary.isFinal, true), + hasSigHashNone: parsedPsbts.reduce( + (acc, psbt) => acc || (psbt.summary as btcTransaction.PsbtSummary)?.hasSigHashNone, + false, + ), + hasSigHashSingle: parsedPsbts.reduce( + (acc, psbt) => acc || (psbt.summary as btcTransaction.PsbtSummary)?.hasSigHashSingle, + false, + ), + } as PsbtSummary, + runeSummary: { + burns: parsedPsbts.map((psbt) => psbt.runeSummary?.burns ?? []).flat(), + transfers: parsedPsbts.map((psbt) => psbt.runeSummary?.transfers ?? []).flat(), + receipts: parsedPsbts.map((psbt) => psbt.runeSummary?.receipts ?? []).flat(), + mint: undefined, + inputsHadRunes: false, + } as RuneSummary, + }), + [parsedPsbts], + ); const handlePsbtParsing = useCallback( - async (psbt: SignMultiplePsbtPayload, index: number) => { + async (psbt: SignMultiplePsbtPayload, index: number): Promise => { try { const parsedPsbt = new btcTransaction.EnhancedPsbt(txnContext, psbt.psbtBase64); const summary = await parsedPsbt.getSummary(); @@ -112,13 +147,15 @@ function SignBatchPsbtRequest() { useEffect(() => { (async () => { - const parsedPsbtsResult = await Promise.all(payload.psbts.map(handlePsbtParsing)); - if (parsedPsbtsResult.some((item) => item === undefined)) { - return setIsLoading(false); + const parsedPsbtsRes = await Promise.all(payload.psbts.map(handlePsbtParsing)); + if (parsedPsbtsRes.some((item) => item === undefined)) { + setIsLoading(false); + return; } - setParsedPsbts( - parsedPsbtsResult as { summary: PsbtSummary; runeSummary: RuneSummary | undefined }[], + const validParsedPsbts = parsedPsbtsRes.filter( + (item): item is ParsedPsbt => item !== undefined, ); + setParsedPsbts(validParsedPsbts); setIsLoading(false); })(); }, [payload.psbts.length, handlePsbtParsing]); @@ -171,26 +208,22 @@ function SignBatchPsbtRequest() { for (const psbt of payload.psbts) { // eslint-disable-next-line no-await-in-loop await delay(100); - // eslint-disable-next-line no-await-in-loop const signedPsbt = await confirmSignPsbt(psbt); signedPsbts.push({ txId: signedPsbt.txId, psbtBase64: signedPsbt.signingResponse, }); - if (payload.psbts.findIndex((item) => item === psbt) !== payload.psbts.length - 1) { setSigningPsbtIndex((prevIndex) => prevIndex + 1); } } - trackMixPanel(AnalyticsEvents.TransactionConfirmed, { protocol: 'bitcoin', action: 'sign-psbt', wallet_type: selectedAccount.accountType || 'software', batch: payload.psbts.length, }); - setIsSigningComplete(true); setIsSigning(false); @@ -231,33 +264,18 @@ function SignBatchPsbtRequest() { window.close(); }; - const totalNetAmount = parsedPsbts.reduce( - (sum, psbt) => - psbt && psbt.summary - ? sum.plus( - new BigNumber( - getNetAmount({ - inputs: psbt.summary.inputs, - outputs: psbt.summary.outputs, - btcAddress: selectedAccount.btcAddress, - ordinalsAddress: selectedAccount.ordinalsAddress, - }), - ), - ) - : sum, - new BigNumber(0), - ); - const totalFeeAmount = parsedPsbts.reduce((sum, psbt) => { - const feeAmount = psbt.summary.feeOutput?.amount ?? 0; - return sum.plus(new BigNumber(feeAmount)); - }, new BigNumber(0)); - const hasOutputScript = useMemo( () => parsedPsbts.some((psbt) => psbt.summary.outputs.some((output) => isScriptOutput(output))), [parsedPsbts.length], ); const signingStatus: ConfirmationStatus = isSigningComplete ? 'SUCCESS' : 'LOADING'; + const runeBurns = parsedPsbts.map((psbt) => psbt.runeSummary?.burns ?? []).flat(); + const runeDelegations = parsedPsbts + .filter((psbt) => !psbt.summary.isFinal) + .map((psbt) => psbt.runeSummary?.transfers ?? []) + .flat(); + const hasSomeRuneDelegation = runeDelegations.length > 0; if (isSigning || isSigningComplete) { return ( @@ -278,14 +296,6 @@ function SignBatchPsbtRequest() { ); } - const transactionIsFinal = parsedPsbts.reduce((acc, psbt) => acc && psbt.summary.isFinal, true); - const runeBurns = parsedPsbts.map((psbt) => psbt.runeSummary?.burns ?? []).flat(); - const runeDelegations = parsedPsbts - .filter((psbt) => !psbt.summary.isFinal) - .map((psbt) => psbt.runeSummary?.transfers ?? []) - .flat(); - const hasSomeRuneDelegation = runeDelegations.length > 0; - return ( <> @@ -302,63 +312,35 @@ function SignBatchPsbtRequest() {
) : ( - - {t('SIGN_TRANSACTIONS', { count: parsedPsbts.length })} - - setReviewTransaction(true)}> - {t('REVIEW_ALL')} - - - {inscriptionToShow && ( - setInscriptionToShow(undefined)} - inscription={{ - content_type: inscriptionToShow.contentType, - id: inscriptionToShow.id, - inscription_number: inscriptionToShow.number, - }} - /> - )} - {hasSomeRuneDelegation && } - psbt.summary.inputs).flat()} - outputs={parsedPsbts.map((psbt) => psbt.summary.outputs).flat()} - hasExternalInputs={parsedPsbts - .map((psbt) => psbt.summary.inputs) - .flat() - .some( - (input) => - input.extendedUtxo.address !== selectedAccount.btcAddress && - input.extendedUtxo.address !== selectedAccount.ordinalsAddress, - )} - runeTransfers={parsedPsbts - .map((psbt) => psbt.runeSummary?.transfers ?? []) - .flat()} - netAmount={(totalNetAmount.toNumber() + totalFeeAmount.toNumber()) * -1} - transactionIsFinal={transactionIsFinal} - onShowInscription={setInscriptionToShow} - /> - psbt.summary.outputs).flat()} - hasExternalInputs={parsedPsbts - .map((psbt) => psbt.summary.inputs) - .flat() - .some( - (input) => - input.extendedUtxo.address !== selectedAccount.btcAddress && - input.extendedUtxo.address !== selectedAccount.ordinalsAddress, + + + {t('SIGN_TRANSACTIONS', { count: parsedPsbts.length })} + + setReviewTransaction(true)}> + {t('REVIEW_ALL')} + + + {inscriptionToShow && ( + setInscriptionToShow(undefined)} + inscription={{ + content_type: inscriptionToShow.contentType, + id: inscriptionToShow.id, + inscription_number: inscriptionToShow.number, + }} + /> + )} + {hasSomeRuneDelegation && } + + + {!hasSomeRuneDelegation && } + psbt.runeSummary?.mint)} /> + + {hasOutputScript && + !parsedPsbts.some((psbt) => psbt.runeSummary !== undefined) && ( + )} - runeReceipts={parsedPsbts.map((psbt) => psbt.runeSummary?.receipts ?? []).flat()} - onShowInscription={setInscriptionToShow} - netAmount={totalNetAmount.toNumber()} - transactionIsFinal={transactionIsFinal} - /> - {!hasSomeRuneDelegation && } - psbt.runeSummary?.mint)} /> - - {hasOutputScript && !parsedPsbts.some((psbt) => psbt.runeSummary !== undefined) && ( - - )} + )} @@ -389,16 +371,9 @@ function SignBatchPsbtRequest() { {t('TRANSACTION')} {currentPsbtIndex + 1}/{parsedPsbts.length} {!!parsedPsbts[currentPsbtIndex] && ( - + + + )} diff --git a/src/app/screens/signPsbtRequest/index.tsx b/src/app/screens/signPsbtRequest/index.tsx index 9c518f9c2..d4e3bf0a1 100644 --- a/src/app/screens/signPsbtRequest/index.tsx +++ b/src/app/screens/signPsbtRequest/index.tsx @@ -24,8 +24,7 @@ import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import useSignPsbt from './useSignPsbt'; import useSignPsbtValidationGate from './useSignPsbtValidationGate'; -// TODO: export this from core -type PSBTSummary = Awaited>; +type PSBTSummary = btcTransaction.PsbtSummary; function SignPsbtRequest() { const navigate = useNavigate(); @@ -47,14 +46,11 @@ function SignPsbtRequest() { payload, parsedPsbt, }); - // extend in future if necessary const isInAppPsbt = magicEdenPsbt && runeId; useTrackMixPanelPageViewed(); - useSignPsbtValidationGate({ payload, parsedPsbt }); - const { network } = useWalletSelector(); const { submitRuneSellPsbt } = useSubmitRuneSellPsbt(); @@ -64,6 +60,7 @@ function SignPsbtRequest() { parsedPsbt .getSummary() + // TODO move this block into useSignPsbt .then(async (txSummary) => { setSummary(txSummary); if (hasRunesSupport) { @@ -100,7 +97,7 @@ function SignPsbtRequest() { wallet_type: selectedAccount?.accountType || 'software', }); if (ledgerTransport) { - await ledgerTransport?.close(); + await ledgerTransport.close(); } if (signedPsbt && magicEdenPsbt && runeId) { return await submitRuneSellPsbt(signedPsbt, location.state.selectedRune?.name ?? '') @@ -178,17 +175,11 @@ function SignPsbtRequest() { return (