refactor: psbt uxui, closes #3849

This commit is contained in:
fbwoolf
2023-06-30 11:32:34 -05:00
committed by Fara Woolf
parent 848e0fcb46
commit e48726e6a5
71 changed files with 1753 additions and 901 deletions

View File

@@ -6,10 +6,6 @@ export function formatMoney({ amount, symbol, decimals }: Money) {
return `${amount.shiftedBy(-decimals).toString()} ${symbol}`;
}
export function formatMoneyWithoutSymbol({ amount, decimals }: Money) {
return `${amount.shiftedBy(-decimals).toString()}`;
}
export function formatMoneyPadded({ amount, symbol, decimals }: Money) {
return `${amount.shiftedBy(-decimals).toFormat(decimals)} ${symbol}`;
}

View File

@@ -33,10 +33,8 @@ export function useRpcSignPsbtParams() {
const allowedSighash = searchParams.getAll('allowedSighash');
const signAtIndex = searchParams.getAll('signAtIndex');
if (!requestId || !psbtHex || !origin) throw new Error('Invalid params');
return useMemo(
() => ({
return useMemo(() => {
return {
origin,
tabId: tabId ?? 0,
requestId,
@@ -45,7 +43,6 @@ export function useRpcSignPsbtParams() {
allowedSighash.map(h => Number(h)) as btc.SignatureHash[]
),
signAtIndex: undefinedIfLengthZero(signAtIndex.map(h => Number(h))),
}),
[allowedSighash, origin, psbtHex, requestId, signAtIndex, tabId]
);
};
}, [allowedSighash, origin, psbtHex, requestId, signAtIndex, tabId]);
}

View File

@@ -33,6 +33,8 @@ export function GenericErrorLayout(props: GenericErrorProps) {
lineHeight="1.6"
mt="base"
textAlign="center"
width="100%"
wordWrap="break-word"
>
{body}
</Text>

View File

@@ -5,13 +5,13 @@ import { Header } from '@app/components/header';
import { GenericErrorLayout } from './generic-error.layout';
interface ErrorProps {
interface GenericErrorProps {
body: string;
helpTextList: ReactNode[];
onClose?(): void;
title: string;
}
export function GenericError(props: ErrorProps) {
export function GenericError(props: GenericErrorProps) {
const { body, helpTextList, onClose = () => window.close(), title } = props;
useRouteHeader(<Header hideActions />);

View File

@@ -0,0 +1,5 @@
import { Box, BoxProps } from '@stacks/ui';
export function Hr(props: BoxProps) {
return <Box as="hr" backgroundColor="#DCDDE2" width="100%" {...props} />;
}

View File

@@ -0,0 +1,30 @@
import { css } from '@emotion/react';
import { bytesToHex } from '@stacks/common';
import { Box } from '@stacks/ui';
import { isTypedArray } from '@shared/utils';
function parseJsonReadable(value: any) {
if (typeof value === 'bigint') return value.toString();
if (isTypedArray(value)) return bytesToHex(value);
return value;
}
export function Json(value: any) {
return (
<Box
css={css`
pre {
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
`}
fontSize="14px"
lineHeight="1.7"
mt="loose"
>
<pre>{JSON.stringify(value, (_, v) => parseJsonReadable(v), 2)}</pre>
</Box>
);
}

View File

@@ -1,10 +1,8 @@
import React, { cloneElement, isValidElement } from 'react';
import { Box, BoxProps } from '@stacks/ui';
import { BoxProps } from '@stacks/ui';
function Hr(props: BoxProps) {
return <Box as="hr" width="100%" backgroundColor="#DCDDE2" {...props} />;
}
import { Hr } from '../hr';
interface DividerSeparatorProps extends BoxProps {
children: React.ReactNode;

View File

@@ -1,17 +0,0 @@
import { Box, Text } from '@stacks/ui';
import { PsbtDecodedNodeLayout } from './psbt-decoded-node.layout';
export function PsbtPlaceholderNode() {
return (
<Box background="white" borderRadius="16px" p="loose">
<Text fontWeight={500}>Inputs</Text>
<PsbtDecodedNodeLayout value="No inputs will be spent" />
<hr />
<Text fontWeight={500} mt="loose">
Outputs
</Text>
<PsbtDecodedNodeLayout value="No outputs will transfer" />
</Box>
);
}

View File

@@ -1,36 +0,0 @@
import { css } from '@emotion/react';
import { bytesToHex } from '@stacks/common';
import { Box } from '@stacks/ui';
import { isTypedArray } from '@shared/utils';
import { DecodedPsbt } from '@app/features/psbt-signer/hooks/use-psbt-signer';
function parseJsonReadable(value: any) {
if (typeof value === 'bigint') return value.toString();
if (isTypedArray(value)) return bytesToHex(value);
return value;
}
interface PsbtDecodedRequestAdvancedProps {
psbt: DecodedPsbt;
}
export function PsbtDecodedRequestAdvanced({ psbt }: PsbtDecodedRequestAdvancedProps) {
return (
<Box background="white" borderRadius="16px" p="loose">
<Box
css={css`
pre {
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
`}
fontSize="14px"
lineHeight="1.7"
>
<pre>{JSON.stringify(psbt, (_, value) => parseJsonReadable(value), 2)}</pre>
</Box>
</Box>
);
}

View File

@@ -1,43 +0,0 @@
import * as btc from '@scure/btc-signer';
import {
PsbtDecodedUtxosMainnet,
PsbtDecodedUtxosTestnet,
} from '@app/features/psbt-signer/hooks/use-psbt-decoded-utxos';
import { PsbtPlaceholderNode } from '../psbt-decoded-request-node/psbt-placeholder-node';
import { PsbtUnsignedInputList } from '../psbt-unsigned-input-list/psbt-unsigned-input-list';
import { PsbtUnsignedOutputList } from '../psbt-unsigned-output-list/psbt-unsigned-output-list';
interface PsbtDecodedRequestSimpleProps {
bitcoinAddressNativeSegwit: string;
bitcoinAddressTaproot: string;
inputs: PsbtDecodedUtxosMainnet | PsbtDecodedUtxosTestnet;
outputs: btc.TransactionOutputRequired[];
showPlaceholder: boolean;
}
export function PsbtDecodedRequestSimple({
bitcoinAddressNativeSegwit,
bitcoinAddressTaproot,
outputs,
showPlaceholder,
inputs,
}: PsbtDecodedRequestSimpleProps) {
if (showPlaceholder) return <PsbtPlaceholderNode />;
return (
<>
<PsbtUnsignedInputList
addressNativeSegwit={bitcoinAddressNativeSegwit}
addressTaproot={bitcoinAddressTaproot}
inputs={inputs}
/>
<PsbtUnsignedOutputList
addressNativeSegwit={bitcoinAddressNativeSegwit}
addressTaproot={bitcoinAddressTaproot}
outputs={outputs}
/>
</>
);
}

View File

@@ -1,33 +0,0 @@
import { Flex, Text, color } from '@stacks/ui';
interface PsbtDecodedRequestViewToggleProps {
onSetShowAdvancedView(): void;
shouldDefaultToAdvancedView: boolean;
showAdvancedView: boolean;
}
export function PsbtDecodedRequestViewToggle({
onSetShowAdvancedView,
shouldDefaultToAdvancedView,
showAdvancedView,
}: PsbtDecodedRequestViewToggleProps) {
return (
<Flex
_hover={{ cursor: 'pointer' }}
justifyContent="flex-end"
as="button"
onClick={onSetShowAdvancedView}
pt="tight"
px="loose"
width="100%"
>
<Text
color={color('accent')}
display={shouldDefaultToAdvancedView ? 'none' : 'block'}
fontSize={1}
py="tight"
>
{showAdvancedView ? 'Hide advanced view' : 'Show advanced view'}
</Text>
</Flex>
);
}

View File

@@ -1,61 +0,0 @@
import * as btc from '@scure/btc-signer';
import { Stack, color } from '@stacks/ui';
import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
import { useCurrentAccountTaprootIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks';
import { usePsbtDecodedRequest } from '../../hooks/use-psbt-decoded-request';
import { DecodedPsbt } from '../../hooks/use-psbt-signer';
import { PsbtDecodedRequestAdvanced } from './psbt-decoded-request-views/psbt-decoded-request-advanced';
import { PsbtDecodedRequestSimple } from './psbt-decoded-request-views/psbt-decoded-request-simple';
import { PsbtDecodedRequestViewToggle } from './psbt-decoded-request-views/psbt-decoded-request-view-toggle';
interface PsbtDecodedRequestProps {
psbt: DecodedPsbt;
}
export function PsbtDecodedRequest({ psbt }: PsbtDecodedRequestProps) {
const nativeSegwitSigner = useCurrentAccountNativeSegwitIndexZeroSigner();
const { address: bitcoinAddressTaproot } = useCurrentAccountTaprootIndexZeroSigner();
const unsignedInputs: btc.TransactionInputRequired[] = psbt.global.unsignedTx?.inputs ?? [];
const unsignedOutputs: btc.TransactionOutputRequired[] = psbt.global.unsignedTx?.outputs ?? [];
const {
onSetShowAdvancedView,
shouldDefaultToAdvancedView,
shouldShowPlaceholder,
showAdvancedView,
unsignedUtxos,
} = usePsbtDecodedRequest({
unsignedInputs,
unsignedOutputs,
});
return (
<Stack
backgroundColor={color('border')}
border="4px solid"
borderColor={color('border')}
borderRadius="20px"
paddingBottom="tight"
spacing="extra-tight"
width="100%"
>
{showAdvancedView || shouldDefaultToAdvancedView ? (
<PsbtDecodedRequestAdvanced psbt={psbt} />
) : (
<PsbtDecodedRequestSimple
bitcoinAddressNativeSegwit={nativeSegwitSigner.address}
bitcoinAddressTaproot={bitcoinAddressTaproot}
inputs={unsignedUtxos}
outputs={unsignedOutputs}
showPlaceholder={shouldShowPlaceholder}
/>
)}
<PsbtDecodedRequestViewToggle
onSetShowAdvancedView={onSetShowAdvancedView}
shouldDefaultToAdvancedView={shouldDefaultToAdvancedView}
showAdvancedView={showAdvancedView}
/>
</Stack>
);
}

View File

@@ -1,49 +0,0 @@
import { truncateMiddle } from '@stacks/ui-utils';
import { logger } from '@shared/logger';
import { createMoney } from '@shared/models/money.model';
import { formatMoneyWithoutSymbol, i18nFormatCurrency } from '@app/common/money/format-money';
import { OrdApiInscriptionTxOutput } from '@app/query/bitcoin/ordinals/ordinals-aware-utxo.query';
import { TaprootUtxo } from '@app/query/bitcoin/ordinals/use-taproot-address-utxos.query';
import { useCalculateBitcoinFiatValue } from '@app/query/common/market-data/market-data.hooks';
import { PsbtDecodedNodeLayout } from '../../psbt-decoded-request-node/psbt-decoded-node.layout';
import { PsbtUnsignedInputWithInscription } from './psbt-unsigned-input-with-inscription';
interface PsbtUnsignedInputItemWithPossibleInscriptionProps {
addressNativeSegwit: string;
addressTaproot: string;
utxo: TaprootUtxo & OrdApiInscriptionTxOutput;
}
export function PsbtUnsignedInputItemWithPossibleInscription({
addressNativeSegwit,
addressTaproot,
utxo,
}: PsbtUnsignedInputItemWithPossibleInscriptionProps) {
const calculateBitcoinFiatValue = useCalculateBitcoinFiatValue();
const isInputCurrentAddress =
utxo.address === addressNativeSegwit || utxo.address === addressTaproot;
const inputValueAsMoney = createMoney(utxo.value, 'BTC');
if (!utxo.address) {
logger.error('UTXO does not have an address');
return null;
}
return utxo.inscriptions ? (
<PsbtUnsignedInputWithInscription
address={utxo.address}
path={utxo.inscriptions}
value={inputValueAsMoney}
/>
) : (
<PsbtDecodedNodeLayout
hoverLabel={utxo.address}
subtitle={truncateMiddle(utxo.address)}
subValue={i18nFormatCurrency(calculateBitcoinFiatValue(inputValueAsMoney))}
value={`${isInputCurrentAddress ? '-' : '+'}${formatMoneyWithoutSymbol(inputValueAsMoney)}`}
/>
);
}

View File

@@ -1,36 +0,0 @@
import { truncateMiddle } from '@stacks/ui-utils';
import { createMoney } from '@shared/models/money.model';
import { BitcoinTransactionVectorOutput } from '@shared/models/transactions/bitcoin-transaction.model';
import { formatMoneyWithoutSymbol, i18nFormatCurrency } from '@app/common/money/format-money';
import { useCalculateBitcoinFiatValue } from '@app/query/common/market-data/market-data.hooks';
import { PsbtDecodedNodeLayout } from '../../psbt-decoded-request-node/psbt-decoded-node.layout';
interface PsbtUnsignedInputItemProps {
addressNativeSegwit: string;
addressTaproot: string;
utxo: BitcoinTransactionVectorOutput;
}
export function PsbtUnsignedInputItem({
addressNativeSegwit,
addressTaproot,
utxo,
}: PsbtUnsignedInputItemProps) {
const calculateBitcoinFiatValue = useCalculateBitcoinFiatValue();
const isInputCurrentAddress =
utxo.scriptpubkey_address === addressNativeSegwit ||
utxo.scriptpubkey_address === addressTaproot;
const inputValueAsMoney = createMoney(utxo.value, 'BTC');
return (
<PsbtDecodedNodeLayout
hoverLabel={utxo.scriptpubkey_address}
subtitle={truncateMiddle(utxo.scriptpubkey_address)}
subValue={i18nFormatCurrency(calculateBitcoinFiatValue(inputValueAsMoney))}
value={`${isInputCurrentAddress ? '-' : '+'}${formatMoneyWithoutSymbol(inputValueAsMoney)}`}
/>
);
}

View File

@@ -1,12 +0,0 @@
import { Box, Text } from '@stacks/ui';
import { HasChildren } from '@app/common/has-children';
export function PsbtUnsignedInputListLayout({ children }: HasChildren) {
return (
<Box background="white" borderTopLeftRadius="16px" borderTopRightRadius="16px" p="loose">
<Text fontWeight={500}>Inputs</Text>
{children}
</Box>
);
}

View File

@@ -1,61 +0,0 @@
import { Box } from '@stacks/ui';
import { truncateMiddle } from '@stacks/ui-utils';
import { Money } from '@shared/models/money.model';
import { isUndefined } from '@shared/utils';
import { formatMoneyWithoutSymbol } from '@app/common/money/format-money';
import { openInNewTab } from '@app/common/utils/open-in-new-tab';
import { OrdinalIcon } from '@app/components/icons/ordinal-icon';
import { InscriptionPreview } from '@app/components/inscription-preview-card/components/inscription-preview';
import { LoadingSpinner } from '@app/components/loading-spinner';
import { useInscription } from '@app/query/bitcoin/ordinals/inscription.hooks';
import { PsbtDecodedNodeLayout } from '../../psbt-decoded-request-node/psbt-decoded-node.layout';
interface PsbtUnsignedInputWithInscriptionProps {
address: string;
path: string;
value: Money;
}
export function PsbtUnsignedInputWithInscription({
address,
value,
path,
}: PsbtUnsignedInputWithInscriptionProps) {
const {
isLoading,
isError,
data: inscription,
} = useInscription(path.replace('/inscription/', ''));
if (isLoading)
return (
<Box my="loose">
<LoadingSpinner />
</Box>
);
if (isError || isUndefined(inscription))
return (
<PsbtDecodedNodeLayout
hoverLabel={address}
image={<OrdinalIcon />}
subtitle={truncateMiddle(address)}
subValue="# Unknown"
title="No data"
value={`-${formatMoneyWithoutSymbol(value)}`}
/>
);
return (
<PsbtDecodedNodeLayout
hoverLabel={address}
image={<InscriptionPreview inscription={inscription} height="40px" width="40px" />}
subtitle={truncateMiddle(address)}
subValue={`#${inscription.number}`}
subValueAction={() => openInNewTab(inscription.infoUrl)}
title="Ordinal inscription"
value={`-${formatMoneyWithoutSymbol(value)}`}
/>
);
}

View File

@@ -1,62 +0,0 @@
import {
PsbtDecodedUtxosMainnet,
PsbtDecodedUtxosTestnet,
} from '@app/features/psbt-signer/hooks/use-psbt-decoded-utxos';
import { PsbtDecodedNodeLayout } from '../psbt-decoded-request-node/psbt-decoded-node.layout';
import { PsbtUnsignedInputItem } from './components/psbt-unsigned-input-item';
import { PsbtUnsignedInputItemWithPossibleInscription } from './components/psbt-unsigned-input-item-with-possible-inscription';
import { PsbtUnsignedInputListLayout } from './components/psbt-unsigned-input-list.layout';
interface PsbtUnsignedInputListProps {
addressNativeSegwit: string;
addressTaproot: string;
inputs: PsbtDecodedUtxosMainnet | PsbtDecodedUtxosTestnet;
}
export function PsbtUnsignedInputList({
addressNativeSegwit,
addressTaproot,
inputs,
}: PsbtUnsignedInputListProps) {
if (!inputs.utxos.length)
return (
<PsbtUnsignedInputListLayout>
<PsbtDecodedNodeLayout value="No inputs found" />
</PsbtUnsignedInputListLayout>
);
switch (inputs.network) {
case 'mainnet':
return (
<PsbtUnsignedInputListLayout>
{inputs.utxos.map((utxo, i) => {
return (
<PsbtUnsignedInputItemWithPossibleInscription
addressNativeSegwit={addressNativeSegwit}
addressTaproot={addressTaproot}
key={i}
utxo={utxo}
/>
);
})}
</PsbtUnsignedInputListLayout>
);
case 'testnet':
return (
<PsbtUnsignedInputListLayout>
{inputs.utxos.map((utxo, i) => {
return (
<PsbtUnsignedInputItem
addressNativeSegwit={addressNativeSegwit}
addressTaproot={addressTaproot}
key={i}
utxo={utxo}
/>
);
})}
</PsbtUnsignedInputListLayout>
);
default:
return null;
}
}

View File

@@ -1,40 +0,0 @@
import * as btc from '@scure/btc-signer';
import { truncateMiddle } from '@stacks/ui-utils';
import { getAddressFromOutScript } from '@shared/crypto/bitcoin/bitcoin.utils';
import { createMoney } from '@shared/models/money.model';
import { formatMoneyWithoutSymbol, i18nFormatCurrency } from '@app/common/money/format-money';
import { useCalculateBitcoinFiatValue } from '@app/query/common/market-data/market-data.hooks';
import { useCurrentNetwork } from '@app/store/networks/networks.selectors';
import { PsbtDecodedNodeLayout } from '../../psbt-decoded-request-node/psbt-decoded-node.layout';
interface PsbtUnsignedOutputItemProps {
addressNativeSegwit: string;
addressTaproot: string;
output: btc.TransactionOutputRequired;
}
export function PsbtUnsignedOutputItem({
addressNativeSegwit,
addressTaproot,
output,
}: PsbtUnsignedOutputItemProps) {
const network = useCurrentNetwork();
const calculateBitcoinFiatValue = useCalculateBitcoinFiatValue();
const addressFromScript = getAddressFromOutScript(output.script, network.chain.bitcoin.network);
const isOutputCurrentAddress =
addressFromScript === addressNativeSegwit || addressFromScript === addressTaproot;
const outputValueAsMoney = createMoney(output.amount, 'BTC');
return (
<PsbtDecodedNodeLayout
hoverLabel={addressFromScript}
subtitle={truncateMiddle(addressFromScript)}
subValue={i18nFormatCurrency(calculateBitcoinFiatValue(outputValueAsMoney))}
value={`${isOutputCurrentAddress ? '+' : ' '}${formatMoneyWithoutSymbol(outputValueAsMoney)}`}
/>
);
}

View File

@@ -1,12 +0,0 @@
import { Box, Text } from '@stacks/ui';
import { HasChildren } from '@app/common/has-children';
export function PsbtUnsignedOutputListLayout({ children }: HasChildren) {
return (
<Box background="white" borderTopLeftRadius="16px" borderTopRightRadius="16px" p="loose">
<Text fontWeight={500}>Outputs</Text>
{children}
</Box>
);
}

View File

@@ -1,38 +0,0 @@
import * as btc from '@scure/btc-signer';
import { PsbtDecodedNodeLayout } from '../psbt-decoded-request-node/psbt-decoded-node.layout';
import { PsbtUnsignedOutputItem } from './components/psbt-unsigned-output-item';
import { PsbtUnsignedOutputListLayout } from './components/psbt-unsigned-output-list.layout';
interface PsbtUnsignedOutputListProps {
addressNativeSegwit: string;
addressTaproot: string;
outputs: btc.TransactionOutputRequired[];
}
export function PsbtUnsignedOutputList({
addressNativeSegwit,
addressTaproot,
outputs,
}: PsbtUnsignedOutputListProps) {
if (!outputs.length)
return (
<PsbtUnsignedOutputListLayout>
<PsbtDecodedNodeLayout value="No outputs found" />
</PsbtUnsignedOutputListLayout>
);
return (
<PsbtUnsignedOutputListLayout>
{outputs.map((output, i) => {
return (
<PsbtUnsignedOutputItem
addressNativeSegwit={addressNativeSegwit}
addressTaproot={addressTaproot}
key={i}
output={output}
/>
);
})}
</PsbtUnsignedOutputListLayout>
);
}

View File

@@ -0,0 +1,26 @@
import { truncateMiddle } from '@stacks/ui-utils';
import { createMoney } from '@shared/models/money.model';
import { formatMoney } from '@app/common/money/format-money';
import { PsbtInput } from '@app/features/psbt-signer/hooks/use-parsed-inputs';
import { PsbtInputOutputItemLayout } from '../../psbt-input-output-item.layout';
const pillHoverLabel = 'Your approval is needed to complete this transaction.';
export function PsbtInputItem(props: { utxo: PsbtInput }) {
const { utxo } = props;
return (
<PsbtInputOutputItemLayout
address={truncateMiddle(utxo.address)}
addressHoverLabel={utxo.address}
amount={formatMoney(createMoney(utxo.value, 'BTC'))}
pillHoverLabel={utxo.sign ? pillHoverLabel : undefined}
pillLabel={utxo.sign ? <>Approve</> : undefined}
txId={truncateMiddle(utxo.txid)}
txIdHoverLabel={utxo.txid}
/>
);
}

View File

@@ -0,0 +1,11 @@
import { Box } from '@stacks/ui';
import { HasChildren } from '@app/common/has-children';
export function PsbtInputListLayout({ children }: HasChildren) {
return (
<Box pb="extra-loose">
<Box borderLeft="1px solid #DCDDE2">{children}</Box>
</Box>
);
}

View File

@@ -0,0 +1,14 @@
import { PsbtInput } from '@app/features/psbt-signer/hooks/use-parsed-inputs';
import { PsbtInputItem } from './components/psbt-input-item';
import { PsbtInputListLayout } from './components/psbt-input-list.layout';
export function PsbtInputList({ inputs }: { inputs: PsbtInput[] }) {
return (
<PsbtInputListLayout>
{inputs.map((input, i) => (
<PsbtInputItem key={i} utxo={input} />
))}
</PsbtInputListLayout>
);
}

View File

@@ -0,0 +1,107 @@
import { FiCopy } from 'react-icons/fi';
import { Box, Flex, Text, color, useClipboard } from '@stacks/ui';
import { useExplorerLink } from '@app/common/hooks/use-explorer-link';
import { Flag } from '@app/components/layout/flag';
import { SpaceBetween } from '@app/components/layout/space-between';
import { Link } from '@app/components/link';
import { Tooltip } from '@app/components/tooltip';
interface PsbtInputOutputItemLayoutProps {
address: string;
addressHoverLabel?: string;
amount: string;
pillHoverLabel?: string;
pillLabel?: React.JSX.Element;
txId?: string;
txIdHoverLabel?: string;
}
export function PsbtInputOutputItemLayout({
address,
addressHoverLabel,
amount,
pillHoverLabel,
pillLabel,
txId,
txIdHoverLabel,
}: PsbtInputOutputItemLayoutProps) {
const { onCopy, hasCopied } = useClipboard(addressHoverLabel ?? '');
const { handleOpenTxLink } = useExplorerLink();
return (
<Flag align="middle" img={<></>} mt="loose" spacing="base">
<SpaceBetween>
<Flex alignItems="center">
<Text color={color('text-caption')} fontSize={1} mr="extra-tight">
{address}
</Text>
<Tooltip
disabled={!addressHoverLabel}
hideOnClick={false}
label={hasCopied ? 'Copied!' : addressHoverLabel}
labelProps={{ wordWrap: 'break-word' }}
maxWidth="230px"
placement="bottom"
>
<Box
as="button"
color={color('text-caption')}
display="flex"
onClick={onCopy}
type="button"
>
{addressHoverLabel ? <FiCopy size="16px" /> : null}
</Box>
</Tooltip>
{pillLabel ? (
<Tooltip label={pillHoverLabel} maxWidth="200px" placement="bottom">
<Box
border="1px solid #DCDDE2"
borderRadius="24px"
lineHeight="16px"
ml="base-tight"
px="tight"
>
<Text color={color('text-caption')} fontSize={0}>
{pillLabel}
</Text>
</Box>
</Tooltip>
) : null}
</Flex>
<Text color={color('text-caption')} fontSize={1}>
{amount}
</Text>
</SpaceBetween>
<Box mt="tight">
{txId && txIdHoverLabel ? (
<Link
_hover={{ textDecoration: 'none' }}
fontSize="14px"
mr="4px !important"
onClick={() =>
handleOpenTxLink({
blockchain: 'bitcoin',
txid: txIdHoverLabel ?? '',
})
}
>
<Tooltip
disabled={!txIdHoverLabel}
hideOnClick={false}
label={txIdHoverLabel}
labelProps={{ wordWrap: 'break-word' }}
maxWidth="230px"
placement="bottom"
>
<Text color={color('brand')} fontSize={1} mr="extra-tight">
{txId}
</Text>
</Tooltip>
</Link>
) : null}
</Box>
</Flag>
);
}

View File

@@ -0,0 +1,34 @@
import { truncateMiddle } from '@stacks/ui-utils';
import { createMoney } from '@shared/models/money.model';
import { formatMoney } from '@app/common/money/format-money';
import { PsbtOutput } from '@app/features/psbt-signer/hooks/use-parsed-outputs';
import { PsbtInputOutputItemLayout } from '../../psbt-input-output-item.layout';
const pillHoverLabel = 'Value youll receive after this transaction is complete.';
interface PsbtOutputItemProps {
addressNativeSegwit: string;
addressTaproot: string;
output: PsbtOutput;
}
export function PsbtOutputItem({
addressNativeSegwit,
addressTaproot,
output,
}: PsbtOutputItemProps) {
const isOutputCurrentAddress =
output.address === addressNativeSegwit || output.address === addressTaproot;
return (
<PsbtInputOutputItemLayout
address={truncateMiddle(output.address)}
addressHoverLabel={output.address}
amount={formatMoney(createMoney(Number(output.value), 'BTC'))}
pillHoverLabel={isOutputCurrentAddress ? pillHoverLabel : undefined}
pillLabel={isOutputCurrentAddress ? <>You</> : undefined}
/>
);
}

View File

@@ -0,0 +1,11 @@
import { Box } from '@stacks/ui';
import { HasChildren } from '@app/common/has-children';
export function PsbtOutputListLayout({ children }: HasChildren) {
return (
<Box>
<Box borderLeft="1px solid #DCDDE2">{children}</Box>
</Box>
);
}

View File

@@ -0,0 +1,28 @@
import { PsbtOutput } from '@app/features/psbt-signer/hooks/use-parsed-outputs';
import { PsbtOutputItem } from './components/psbt-output-item';
import { PsbtOutputListLayout } from './components/psbt-output-list.layout';
interface PsbtOutputListProps {
addressNativeSegwit: string;
addressTaproot: string;
outputs: PsbtOutput[];
}
export function PsbtOutputList({
addressNativeSegwit,
addressTaproot,
outputs,
}: PsbtOutputListProps) {
return (
<PsbtOutputListLayout>
{outputs.map((output, i) => (
<PsbtOutputItem
addressNativeSegwit={addressNativeSegwit}
addressTaproot={addressTaproot}
key={i}
output={output}
/>
))}
</PsbtOutputListLayout>
);
}

View File

@@ -0,0 +1,49 @@
import { useState } from 'react';
import { PsbtInput } from '@app/features/psbt-signer/hooks/use-parsed-inputs';
import { PsbtOutput } from '@app/features/psbt-signer/hooks/use-parsed-outputs';
import { PsbtRequestDetailsSectionHeader } from '../psbt-request-details-section-header';
import { PsbtRequestDetailsSectionLayout } from '../psbt-request-details-section.layout';
import { PsbtInputList } from './components/psbt-input-list/psbt-input-list';
import { PsbtOutputList } from './components/psbt-output-list/psbt-output-list';
interface PsbtInputsAndOutputsProps {
addressNativeSegwit: string;
addressTaproot: string;
inputs: PsbtInput[];
outputs: PsbtOutput[];
}
export function PsbtInputsAndOutputs({
addressNativeSegwit,
addressTaproot,
outputs,
inputs,
}: PsbtInputsAndOutputsProps) {
const [showDetails, setShowDetails] = useState(false);
if (!inputs.length || !outputs.length) return null;
return (
<PsbtRequestDetailsSectionLayout>
<PsbtRequestDetailsSectionHeader
hasDetails
onSetShowDetails={(value: boolean) => setShowDetails(value)}
showDetails={showDetails}
title={showDetails ? 'Inputs' : 'Inputs and Outputs'}
/>
{showDetails ? (
<>
<PsbtInputList inputs={inputs} />
<PsbtRequestDetailsSectionHeader title="Outputs" />
<PsbtOutputList
addressNativeSegwit={addressNativeSegwit}
addressTaproot={addressTaproot}
outputs={outputs}
/>
</>
) : null}
</PsbtRequestDetailsSectionLayout>
);
}

View File

@@ -1,41 +1,43 @@
import { FiArrowUpRight, FiCopy } from 'react-icons/fi';
import { FiCopy } from 'react-icons/fi';
import { Box, Stack, Text, color, useClipboard } from '@stacks/ui';
import { Box, Text, color, useClipboard } from '@stacks/ui';
import { BtcIcon } from '@app/components/icons/btc-icon';
import { Flag } from '@app/components/layout/flag';
import { SpaceBetween } from '@app/components/layout/space-between';
import { Tooltip } from '@app/components/tooltip';
interface PsbtDecodedNodeLayoutProps {
interface PsbtAddressTotalItemProps {
hoverLabel?: string;
image?: React.JSX.Element;
subtitle?: string;
subValue?: string;
subValueAction?(): void;
title?: string;
value: string;
valueAction?(): void;
}
export function PsbtDecodedNodeLayout({
export function PsbtAddressTotalItem({
hoverLabel,
image,
subtitle,
subValue,
subValueAction,
title,
value,
}: PsbtDecodedNodeLayoutProps) {
valueAction,
}: PsbtAddressTotalItemProps) {
const { onCopy, hasCopied } = useClipboard(hoverLabel ?? '');
return (
<Flag align="middle" img={image ? image : <BtcIcon />} my="loose" spacing="base">
<Flag align="middle" img={image ? image : <BtcIcon />} mt="loose" spacing="base">
<SpaceBetween>
<Text fontSize={2} fontWeight="500">
{title ? title : 'BTC'}
</Text>
<Text fontSize={2} fontWeight="500">
{value}
{title ? title : 'Bitcoin'}
</Text>
<Box as="button" onClick={valueAction} type="button">
<Text color={valueAction ? color('accent') : 'unset'} fontSize={2} fontWeight={500}>
{value}
</Text>
</Box>
</SpaceBetween>
<SpaceBetween mt="tight">
{subtitle ? (
@@ -48,7 +50,6 @@ export function PsbtDecodedNodeLayout({
placement="bottom"
>
<Box
_hover={{ cursor: 'pointer' }}
as="button"
color={color('text-caption')}
display="flex"
@@ -63,12 +64,9 @@ export function PsbtDecodedNodeLayout({
</Tooltip>
) : null}
{subValue ? (
<Stack as="button" isInline onClick={subValueAction} spacing="extra-tight" type="button">
<Text color={subValueAction ? color('accent') : color('text-caption')} fontSize={1}>
{subValue}
</Text>
{subValueAction ? <FiArrowUpRight color={color('accent')} /> : null}
</Stack>
<Text color={color('text-caption')} fontSize={1}>
{subValue}
</Text>
) : null}
</SpaceBetween>
</Flag>

View File

@@ -0,0 +1,64 @@
import { truncateMiddle } from '@stacks/ui-utils';
import { Money } from '@shared/models/money.model';
import { formatMoney, i18nFormatCurrency } from '@app/common/money/format-money';
import { useCalculateBitcoinFiatValue } from '@app/query/common/market-data/market-data.hooks';
import { PsbtAddressTotalItem } from './psbt-address-total-item';
import { PsbtInscription } from './psbt-inscription';
interface PsbtAddressTotalsProps {
accountInscriptionsBeingTransferred?: string[];
accountInscriptionsBeingReceived?: string[];
addressNativeSegwit: string;
addressTaproot: string;
addressNativeSegwitTotal: Money;
addressTaprootTotal: Money;
showNativeSegwitTotal: boolean;
showTaprootTotal: boolean;
}
export function PsbtAddressTotals({
accountInscriptionsBeingTransferred,
accountInscriptionsBeingReceived,
addressNativeSegwit,
addressTaproot,
addressNativeSegwitTotal,
addressTaprootTotal,
showNativeSegwitTotal,
showTaprootTotal,
}: PsbtAddressTotalsProps) {
const calculateBitcoinFiatValue = useCalculateBitcoinFiatValue();
const isTransferringInscriptions = accountInscriptionsBeingTransferred?.length;
const isReceivingInscriptions = accountInscriptionsBeingReceived?.length;
return (
<>
{showNativeSegwitTotal ? (
<PsbtAddressTotalItem
hoverLabel={addressNativeSegwit}
subtitle={truncateMiddle(addressNativeSegwit)}
subValue={i18nFormatCurrency(calculateBitcoinFiatValue(addressNativeSegwitTotal))}
value={formatMoney(addressNativeSegwitTotal)}
/>
) : null}
{isTransferringInscriptions
? accountInscriptionsBeingTransferred.map(path => (
<PsbtInscription key={path} path={path} />
))
: null}
{!isReceivingInscriptions && showTaprootTotal ? (
<PsbtAddressTotalItem
hoverLabel={addressTaproot}
subtitle={truncateMiddle(addressTaproot)}
subValue={i18nFormatCurrency(calculateBitcoinFiatValue(addressTaprootTotal))}
value={formatMoney(addressTaprootTotal)}
/>
) : null}
{isReceivingInscriptions
? accountInscriptionsBeingReceived.map(path => <PsbtInscription key={path} path={path} />)
: null}
</>
);
}

View File

@@ -0,0 +1,46 @@
import { Box } from '@stacks/ui';
import { isUndefined } from '@shared/utils';
import { openInNewTab } from '@app/common/utils/open-in-new-tab';
import { OrdinalIcon } from '@app/components/icons/ordinal-icon';
import { InscriptionPreview } from '@app/components/inscription-preview-card/components/inscription-preview';
import { LoadingSpinner } from '@app/components/loading-spinner';
import { useInscription } from '@app/query/bitcoin/ordinals/inscription.hooks';
import { PsbtAddressTotalItem } from './psbt-address-total-item';
interface PsbtInscriptionProps {
path: string;
}
export function PsbtInscription({ path }: PsbtInscriptionProps) {
const {
isLoading,
isError,
data: inscription,
} = useInscription(path.replace('/inscription/', ''));
if (isLoading)
return (
<Box my="loose">
<LoadingSpinner />
</Box>
);
if (isError || isUndefined(inscription))
return (
<PsbtAddressTotalItem
image={<OrdinalIcon />}
title="Inscription not found"
value="# Unknown"
/>
);
return (
<PsbtAddressTotalItem
image={<InscriptionPreview inscription={inscription} height="40px" width="40px" />}
title="Inscription"
value={`#${inscription.number}`}
valueAction={() => openInNewTab(inscription.infoUrl)}
/>
);
}

View File

@@ -0,0 +1,76 @@
import { Box } from '@stacks/ui';
import { color } from '@stacks/ui-utils';
import { Money } from '@shared/models/money.model';
import { Hr } from '@app/components/hr';
import { PsbtRequestDetailsSectionHeader } from '../psbt-request-details-section-header';
import { PsbtRequestDetailsSectionLayout } from '../psbt-request-details-section.layout';
import { PsbtAddressTotals } from './components/psbt-address-totals';
interface PsbtInputsOutputsTotalsProps {
accountInscriptionsBeingTransferred: string[];
accountInscriptionsBeingReceived: string[];
addressNativeSegwit: string;
addressTaproot: string;
addressNativeSegwitTotal: Money;
addressTaprootTotal: Money;
}
export function PsbtInputsOutputsTotals({
accountInscriptionsBeingTransferred,
accountInscriptionsBeingReceived,
addressNativeSegwit,
addressTaproot,
addressNativeSegwitTotal,
addressTaprootTotal,
}: PsbtInputsOutputsTotalsProps) {
// Transferring (+)
const isNativeSegwitTotalGreaterThanZero = addressNativeSegwitTotal.amount.isGreaterThan(0);
const isTaprootTotalGreaterThanZero = addressTaprootTotal.amount.isGreaterThan(0);
// Receiving (-)
const isNativeSegwitTotalLessThanZero = addressNativeSegwitTotal.amount.isLessThan(0);
const isTaprootTotalLessThanZero = addressTaprootTotal.amount.isLessThan(0);
const isTotalZero =
addressNativeSegwitTotal.amount.isEqualTo(0) && addressTaprootTotal.amount.isEqualTo(0);
const isTransferring = isNativeSegwitTotalGreaterThanZero || isTaprootTotalGreaterThanZero;
const isReceiving = isNativeSegwitTotalLessThanZero || isTaprootTotalLessThanZero;
const showDivider = isTransferring && isReceiving;
if (isTotalZero) return null;
return (
<PsbtRequestDetailsSectionLayout p="unset">
{isTransferring ? (
<Box p="loose">
<PsbtRequestDetailsSectionHeader title="You'll transfer" />
<PsbtAddressTotals
accountInscriptionsBeingTransferred={accountInscriptionsBeingTransferred}
addressNativeSegwit={addressNativeSegwit}
addressTaproot={addressTaproot}
addressNativeSegwitTotal={addressNativeSegwitTotal}
addressTaprootTotal={addressTaprootTotal}
showNativeSegwitTotal={isNativeSegwitTotalGreaterThanZero}
showTaprootTotal={isTaprootTotalGreaterThanZero}
/>
</Box>
) : null}
{showDivider ? <Hr backgroundColor={color('border')} height="3px" /> : null}
{isReceiving ? (
<Box p="loose">
<PsbtRequestDetailsSectionHeader title="You'll receive" />
<PsbtAddressTotals
accountInscriptionsBeingReceived={accountInscriptionsBeingReceived}
addressNativeSegwit={addressNativeSegwit}
addressTaproot={addressTaproot}
addressNativeSegwitTotal={addressNativeSegwitTotal}
addressTaprootTotal={addressTaprootTotal}
showNativeSegwitTotal={isNativeSegwitTotalLessThanZero}
showTaprootTotal={isTaprootTotalLessThanZero}
/>
</Box>
) : null}
</PsbtRequestDetailsSectionLayout>
);
}

View File

@@ -0,0 +1,51 @@
import { FiAlertCircle, FiLock } from 'react-icons/fi';
import { Box, Stack, Text, color } from '@stacks/ui';
import { Tooltip } from '@app/components/tooltip';
import { Title } from '@app/components/typography';
const immutableLabel =
'Any modification to the transaction, including the fee amount or other inputs/outputs, will invalidate the signature.';
const uncertainLabel =
'The transaction details can be altered by other participants. This means the final outcome of the transaction might be different than initially agreed upon.';
export function PsbtRequestDetailsHeader(props: { isPsbtMutable: boolean }) {
const { isPsbtMutable } = props;
const labelColor = isPsbtMutable ? color('feedback-alert') : color('text-caption');
return (
<Stack alignItems="center" isInline spacing="tight">
<Title fontSize={3} fontWeight={500}>
Transaction
</Title>
<Tooltip
label={isPsbtMutable ? immutableLabel : uncertainLabel}
maxWidth="230px"
placement="bottom"
>
<Stack
alignItems="center"
border="1px solid"
borderColor={labelColor}
borderRadius="24px"
isInline
px="tight"
py="extra-tight"
spacing="extra-tight"
>
<Box size="12px">
{isPsbtMutable ? (
<FiAlertCircle color={labelColor} size="12px" />
) : (
<FiLock color={labelColor} size="12px" />
)}
</Box>
<Text color={labelColor} fontSize={0}>
{isPsbtMutable ? 'Uncertain' : 'Certain'}
</Text>
</Stack>
</Tooltip>
</Stack>
);
}

View File

@@ -0,0 +1,38 @@
import { FiArrowUp } from 'react-icons/fi';
import { Stack, Text } from '@stacks/ui';
import { SpaceBetween } from '@app/components/layout/space-between';
interface PsbtRequestDetailsSectionHeaderProps {
hasDetails?: boolean;
onSetShowDetails?(value: boolean): void;
showDetails?: boolean;
title: string;
}
export function PsbtRequestDetailsSectionHeader({
hasDetails,
onSetShowDetails,
showDetails,
title,
}: PsbtRequestDetailsSectionHeaderProps) {
return (
<SpaceBetween>
<Text fontWeight={500}>{title}</Text>
{hasDetails && onSetShowDetails ? (
<Stack alignItems="center" isInline spacing="extra-tight">
<Text
as="button"
fontSize={2}
fontWeight={500}
onClick={() => onSetShowDetails(!showDetails)}
type="button"
>
{showDetails ? 'See less' : 'See details'}
</Text>
{showDetails ? <FiArrowUp /> : <></>}
</Stack>
) : null}
</SpaceBetween>
);
}

View File

@@ -0,0 +1,19 @@
import { Stack, StackProps, color } from '@stacks/ui';
import { HasChildren } from '@app/common/has-children';
export function PsbtRequestDetailsSectionLayout({ children, ...props }: HasChildren & StackProps) {
return (
<Stack
border="4px solid"
borderColor={color('border')}
borderRadius="16px"
p="loose"
spacing="extra-tight"
width="100%"
{...props}
>
{children}
</Stack>
);
}

View File

@@ -0,0 +1,11 @@
import { Stack } from '@stacks/ui';
import { HasChildren } from '@app/common/has-children';
export function PsbtRequestDetailsLayout({ children }: HasChildren) {
return (
<Stack spacing="loose" width="100%">
{children}
</Stack>
);
}

View File

@@ -0,0 +1,32 @@
import { Stack, Text, color } from '@stacks/ui';
import { Money } from '@shared/models/money.model';
import { formatMoney, i18nFormatCurrency } from '@app/common/money/format-money';
import { SpaceBetween } from '@app/components/layout/space-between';
import { useCalculateBitcoinFiatValue } from '@app/query/common/market-data/market-data.hooks';
import { PsbtRequestDetailsSectionLayout } from './psbt-request-details-section.layout';
export function PsbtRequestFee(props: { fee: Money }) {
const { fee } = props;
const calculateBitcoinFiatValue = useCalculateBitcoinFiatValue();
return (
<PsbtRequestDetailsSectionLayout>
<SpaceBetween>
<Text fontSize={2} fontWeight="500">
Transaction fee
</Text>
<Stack alignItems="flex-end" spacing="extra-tight">
<Text fontSize={2} fontWeight="500">
{formatMoney(fee)}
</Text>
<Text color={color('text-caption')} fontSize={1}>
{i18nFormatCurrency(calculateBitcoinFiatValue(fee))}
</Text>
</Stack>
</SpaceBetween>
</PsbtRequestDetailsSectionLayout>
);
}

View File

@@ -0,0 +1,23 @@
import { useState } from 'react';
import { Json } from '@app/components/json';
import { RawPsbt } from '@app/features/psbt-signer/hooks/use-psbt-signer';
import { PsbtRequestDetailsSectionHeader } from './psbt-request-details-section-header';
import { PsbtRequestDetailsSectionLayout } from './psbt-request-details-section.layout';
export function PsbtRequestRaw({ psbt }: { psbt: RawPsbt }) {
const [showDetails, setShowDetails] = useState(false);
return (
<PsbtRequestDetailsSectionLayout>
<PsbtRequestDetailsSectionHeader
hasDetails
onSetShowDetails={(value: boolean) => setShowDetails(value)}
showDetails={showDetails}
title="Raw transaction"
/>
{showDetails ? <Json value={psbt} /> : null}
</PsbtRequestDetailsSectionLayout>
);
}

View File

@@ -0,0 +1,88 @@
import * as btc from '@scure/btc-signer';
import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
import { useCurrentAccountTaprootIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks';
import { useParsedPsbt } from '../../hooks/use-parsed-psbt';
import { RawPsbt } from '../../hooks/use-psbt-signer';
import { PsbtRequestSighashWarningLabel } from '../psbt-request-sighash-warning-label';
import { PsbtInputsAndOutputs } from './components/psbt-inputs-and-outputs/psbt-inputs-and-outputs';
import { PsbtInputsOutputsTotals } from './components/psbt-inputs-outputs-totals/psbt-inputs-outputs-totals';
import { PsbtRequestDetailsHeader } from './components/psbt-request-details-header';
import { PsbtRequestDetailsLayout } from './components/psbt-request-details.layout';
import { PsbtRequestFee } from './components/psbt-request-fee';
import { PsbtRequestRaw } from './components/psbt-request-raw';
function getPsbtTxInputs(psbtTx: btc.Transaction) {
const inputsLength = psbtTx.inputsLength;
const inputs: btc.TransactionInput[] = [];
if (inputsLength === 0) return inputs;
for (let i = 0; i < inputsLength; i++) {
inputs.push(psbtTx.getInput(i));
}
return inputs;
}
function getPsbtTxOutputs(psbtTx: btc.Transaction) {
const outputsLength = psbtTx.outputsLength;
const outputs: btc.TransactionOutput[] = [];
if (outputsLength === 0) return outputs;
for (let i = 0; i < outputsLength; i++) {
outputs.push(psbtTx.getOutput(i));
}
return outputs;
}
interface PsbtDecodedRequestProps {
allowedSighashes?: btc.SignatureHash[];
inputsToSign?: number | number[];
psbtRaw: RawPsbt;
psbtTx: btc.Transaction;
}
export function PsbtRequestDetails({
allowedSighashes,
inputsToSign,
psbtRaw,
psbtTx,
}: PsbtDecodedRequestProps) {
const nativeSegwitSigner = useCurrentAccountNativeSegwitIndexZeroSigner();
const { address: bitcoinAddressTaproot } = useCurrentAccountTaprootIndexZeroSigner();
const inputs = getPsbtTxInputs(psbtTx);
const outputs = getPsbtTxOutputs(psbtTx);
const {
accountInscriptionsBeingTransferred,
accountInscriptionsBeingReceived,
addressNativeSegwitTotal,
addressTaprootTotal,
fee,
isPsbtMutable,
psbtInputs,
psbtOutputs,
shouldDefaultToAdvancedView,
} = useParsedPsbt({ allowedSighashes, inputs, inputsToSign, outputs });
if (shouldDefaultToAdvancedView) return <PsbtRequestRaw psbt={psbtRaw} />;
return (
<PsbtRequestDetailsLayout>
{isPsbtMutable ? <PsbtRequestSighashWarningLabel /> : null}
<PsbtRequestDetailsHeader isPsbtMutable={isPsbtMutable} />
<PsbtInputsOutputsTotals
accountInscriptionsBeingTransferred={accountInscriptionsBeingTransferred}
accountInscriptionsBeingReceived={accountInscriptionsBeingReceived}
addressNativeSegwit={nativeSegwitSigner.address}
addressTaproot={bitcoinAddressTaproot}
addressNativeSegwitTotal={addressNativeSegwitTotal}
addressTaprootTotal={addressTaprootTotal}
/>
<PsbtInputsAndOutputs
addressNativeSegwit={nativeSegwitSigner.address}
addressTaproot={bitcoinAddressTaproot}
inputs={psbtInputs}
outputs={psbtOutputs}
/>
<PsbtRequestRaw psbt={psbtRaw} />
<PsbtRequestFee fee={fee} />
</PsbtRequestDetailsLayout>
);
}

View File

@@ -1,20 +1,38 @@
import { Flex } from '@stacks/ui';
import { Flex, Text } from '@stacks/ui';
import { addPortSuffix, getUrlHostname } from '@app/common/utils';
import { Favicon } from '@app/components/favicon';
import { Flag } from '@app/components/layout/flag';
import { Caption, Title } from '@app/components/typography';
import { useCurrentNetworkState } from '@app/store/networks/networks.hooks';
interface PsbtRequestHeaderProps {
name?: string;
origin: string;
}
export function PsbtRequestHeader({ origin }: PsbtRequestHeaderProps) {
const caption = `${origin} is requesting you sign this PSBT`;
export function PsbtRequestHeader({ name, origin }: PsbtRequestHeaderProps) {
const { chain, isTestnet } = useCurrentNetworkState();
const originAddition = origin ? ` (${getUrlHostname(origin)})` : '';
const testnetAddition = isTestnet
? ` using ${getUrlHostname(chain.stacks.url)}${addPortSuffix(chain.stacks.url)}`
: '';
const displayName = name ?? origin;
const caption = displayName
? `Requested by ${displayName}${originAddition}${testnetAddition}`
: null;
return (
<Flex flexDirection="column" my="loose" width="100%">
<Title fontSize={3} fontWeight={500} mb="base">
Sign transaction
<Title fontSize={4} fontWeight={500} mb="base">
Approve transaction
</Title>
<Text lineHeight="24px" mb="base">
Please review the recipient address, amount, and associated fees. Authorize only
transactions you fully understand.
</Text>
{caption && (
<Flag align="top" img={<Favicon origin={origin} />} pl="tight">
<Caption wordBreak="break-word" lineHeight={1.3}>

View File

@@ -0,0 +1,16 @@
import { Text } from '@stacks/ui';
import { WarningLabel } from '@app/components/warning-label';
export function PsbtRequestSighashWarningLabel() {
return (
<WarningLabel title="Be careful with this transaction" width="100%">
The details you see here are not guaranteed. Be sure to fully trust your counterparty, who can
later modify this transaction to send or receive other assets from your account, and possibly
even drain it.
<Text display="inline" pl="extra-tight" textDecoration="underline">
Learn more
</Text>
</WarningLabel>
);
}

View File

@@ -1,13 +0,0 @@
import { WarningLabel } from '@app/components/warning-label';
export function PsbtRequestAppWarningLabel(props: { appName?: string }) {
const { appName } = props;
const title = `Do not proceed unless you trust ${appName ?? 'Unknown'}!`;
return (
<WarningLabel title={title} width="100%">
Signing this PSBT can have dangerous side effects. Only sign if the PSBT is from a site you
trust.
</WarningLabel>
);
}

View File

@@ -1,9 +1,9 @@
import { Stack } from '@stacks/ui';
interface PsbtRequestLayoutProps {
interface PsbtSignerLayoutProps {
children: React.ReactNode;
}
export function PsbtRequestLayout({ children }: PsbtRequestLayoutProps) {
export function PsbtSignerLayout({ children }: PsbtSignerLayoutProps) {
return (
<Stack
alignItems="center"

View File

@@ -0,0 +1,38 @@
import {
mockInscriptions1,
mockInscriptions2,
mockPsbtInputs1,
mockPsbtInputs2,
mockPsbtOutputs1,
mockPsbtOutputs2,
} from '@tests/mocks/mock-psbts';
import { findOutputsReceivingInscriptions } from './find-outputs-receiving-inscriptions';
describe('find outputs receiving inscriptions', () => {
test('that the correct output receives the inscription (scenario 1)', () => {
const outputsReceivingInscriptions = findOutputsReceivingInscriptions({
inscriptions: mockInscriptions1,
psbtInputs: mockPsbtInputs1,
psbtOutputs: mockPsbtOutputs1,
});
expect(outputsReceivingInscriptions[0]).toEqual({
address: 'bc1p9pnzvq52956jht5deha82qp96pxw0a0tvey6fhdea7vwhf33tarskqq3nr',
inscription:
'/inscription/ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202i0',
});
});
test('that the correct output receives the inscription (scenario 2)', () => {
const outputsReceivingInscriptions = findOutputsReceivingInscriptions({
inscriptions: mockInscriptions2,
psbtInputs: mockPsbtInputs2,
psbtOutputs: mockPsbtOutputs2,
});
expect(outputsReceivingInscriptions[0]).toEqual({
address: 'bc1p9pnzvq52956jht5deha82qp96pxw0a0tvey6fhdea7vwhf33tarskqq3nr',
inscription:
'/inscription/ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202i0',
});
});
});

View File

@@ -0,0 +1,43 @@
import BigNumber from 'bignumber.js';
import { Inscription } from '@shared/models/inscription.model';
import { isDefined } from '@shared/utils';
import { PsbtInput } from './use-parsed-inputs';
import { PsbtOutput } from './use-parsed-outputs';
interface FindOutputsReceivingInscriptionsArgs {
inscriptions: Inscription[];
psbtInputs: PsbtInput[];
psbtOutputs: PsbtOutput[];
}
export function findOutputsReceivingInscriptions({
inscriptions,
psbtInputs,
psbtOutputs,
}: FindOutputsReceivingInscriptionsArgs) {
let inputsSatsTotal = new BigNumber(0);
return psbtInputs
.flatMap(input => {
if (input.inscription) {
const inscription = inscriptions.find(inscription =>
input.inscription?.includes(inscription.id)
);
// Offset is zero indexed, so 1 is added here to match the sats total
const inscriptionTotalOffset = inputsSatsTotal.plus(Number(inscription?.offset) + 1);
let outputsSatsTotal = new BigNumber(0);
for (let output = 0; output < psbtOutputs.length; output++) {
outputsSatsTotal = outputsSatsTotal.plus(psbtOutputs[output].value);
if (inscriptionTotalOffset.isLessThanOrEqualTo(outputsSatsTotal))
return { address: psbtOutputs[output].address, inscription: input.inscription };
}
}
inputsSatsTotal = inputsSatsTotal.plus(input.value);
return;
})
.filter(isDefined);
}

View File

@@ -0,0 +1,107 @@
import { useMemo } from 'react';
import * as btc from '@scure/btc-signer';
import { bytesToHex } from '@stacks/common';
import {
BtcSignerNetwork,
getBtcSignerLibNetworkConfigByMode,
} from '@shared/crypto/bitcoin/bitcoin.network';
import { getAddressFromOutScript } from '@shared/crypto/bitcoin/bitcoin.utils';
import { ensureArray, isDefined, isUndefined } from '@shared/utils';
import { useOrdinalsAwareUtxoQueries } from '@app/query/bitcoin/ordinals/ordinals-aware-utxo.query';
import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
import { useCurrentAccountTaprootIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks';
import { useCurrentNetwork } from '@app/store/networks/networks.selectors';
export interface PsbtInput {
address: string;
index?: number;
inscription?: string;
mutable: boolean;
sign: boolean;
txid: string;
value: number;
}
function getInputAddress(
index: number,
input: btc.TransactionInput,
bitcoinNetwork: BtcSignerNetwork
) {
if (isDefined(input.witnessUtxo))
return getAddressFromOutScript(input.witnessUtxo.script, bitcoinNetwork);
if (isDefined(input.nonWitnessUtxo))
return getAddressFromOutScript(input.nonWitnessUtxo.outputs[index]?.script, bitcoinNetwork);
return '';
}
function getInputValue(index: number, input: btc.TransactionInput) {
if (isDefined(input.witnessUtxo)) return Number(input.witnessUtxo.amount);
if (isDefined(input.nonWitnessUtxo)) return Number(input.nonWitnessUtxo.outputs[index]?.amount);
return 0;
}
interface UseParsedInputsArgs {
allowedSighashes?: btc.SignatureHash[];
inputs: btc.TransactionInput[];
inputsToSign?: number | number[];
}
export function useParsedInputs({ allowedSighashes, inputs, inputsToSign }: UseParsedInputsArgs) {
const network = useCurrentNetwork();
const bitcoinNetwork = getBtcSignerLibNetworkConfigByMode(network.chain.bitcoin.network);
const bitcoinAddressNativeSegwit = useCurrentAccountNativeSegwitIndexZeroSigner().address;
const { address: bitcoinAddressTaproot } = useCurrentAccountTaprootIndexZeroSigner();
const utxosWithInscriptions = useOrdinalsAwareUtxoQueries(inputs).map(query => query.data);
const signAll = isUndefined(inputsToSign);
const psbtInputs = useMemo(
() =>
inputs.map((input, i) => {
const inputAddress = isDefined(input.index)
? getInputAddress(input.index, input, bitcoinNetwork)
: '';
const isCurrentAddress =
inputAddress === bitcoinAddressNativeSegwit || inputAddress === bitcoinAddressTaproot;
// Flags when not signing ALL inputs/outputs (NONE, SINGLE, and ANYONECANPAY)
const canChange =
isCurrentAddress &&
!(!input.sighashType || input.sighashType === 0 || input.sighashType === 1);
// Checks if the sighashType is allowed by the PSBT
const isAllowedToSign =
isUndefined(allowedSighashes) ||
ensureArray(allowedSighashes).some(
type => !input.sighashType || type === input.sighashType
);
// Should we check the sighashType here before it gets to the signing lib?
const toSignAll = isCurrentAddress && signAll;
const toSignIndex =
isCurrentAddress && ensureArray(inputsToSign).some(inputIndex => inputIndex === i);
return {
address: inputAddress,
index: input.index,
inscription: utxosWithInscriptions[i]?.inscriptions,
mutable: canChange,
sign: isAllowedToSign && (toSignAll || toSignIndex),
txid: input.txid ? bytesToHex(input.txid) : '',
value: isDefined(input.index) ? getInputValue(input.index, input) : 0,
};
}),
[
allowedSighashes,
bitcoinAddressNativeSegwit,
bitcoinAddressTaproot,
bitcoinNetwork,
inputs,
inputsToSign,
signAll,
utxosWithInscriptions,
]
);
const isPsbtMutable = useMemo(() => psbtInputs.some(input => input.mutable), [psbtInputs]);
return { isPsbtMutable, parsedInputs: psbtInputs };
}

View File

@@ -0,0 +1,54 @@
import { useMemo } from 'react';
import * as btc from '@scure/btc-signer';
import { NetworkConfiguration } from '@shared/constants';
import { getBtcSignerLibNetworkConfigByMode } from '@shared/crypto/bitcoin/bitcoin.network';
import { getAddressFromOutScript } from '@shared/crypto/bitcoin/bitcoin.utils';
import { logger } from '@shared/logger';
import { isDefined, isUndefined } from '@shared/utils';
import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
import { useCurrentAccountTaprootIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks';
export interface PsbtOutput {
address: string;
mutable: boolean;
sign: boolean;
value: number;
}
interface UseParsedOutputsArgs {
isPsbtMutable: boolean;
outputs: btc.TransactionOutput[];
network: NetworkConfiguration;
}
export function useParsedOutputs({ isPsbtMutable, outputs, network }: UseParsedOutputsArgs) {
const bitcoinAddressNativeSegwit = useCurrentAccountNativeSegwitIndexZeroSigner().address;
const { address: bitcoinAddressTaproot } = useCurrentAccountTaprootIndexZeroSigner();
const bitcoinNetwork = getBtcSignerLibNetworkConfigByMode(network.chain.bitcoin.network);
return useMemo(
() =>
outputs
.map(output => {
if (isUndefined(output.script)) {
logger.error('Output has no script');
return;
}
const outputAddress = getAddressFromOutScript(output.script, bitcoinNetwork);
const isCurrentAddress =
outputAddress === bitcoinAddressNativeSegwit || outputAddress === bitcoinAddressTaproot;
return {
address: outputAddress,
mutable: isPsbtMutable,
sign: isCurrentAddress,
value: Number(output.amount),
};
})
.filter(isDefined),
[bitcoinAddressNativeSegwit, bitcoinAddressTaproot, bitcoinNetwork, isPsbtMutable, outputs]
);
}

View File

@@ -0,0 +1,71 @@
import { useCallback } from 'react';
import * as btc from '@scure/btc-signer';
import { subtractMoney } from '@app/common/money/calculate-money';
import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
import { useCurrentAccountTaprootIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks';
import { useCurrentNetwork } from '@app/store/networks/networks.selectors';
import { useParsedInputs } from './use-parsed-inputs';
import { useParsedOutputs } from './use-parsed-outputs';
import { usePsbtInscriptions } from './use-psbt-inscriptions';
import { usePsbtTotals } from './use-psbt-totals';
interface UseParsedPsbtArgs {
allowedSighashes?: btc.SignatureHash[];
inputs: btc.TransactionInput[];
inputsToSign?: number | number[];
outputs: btc.TransactionOutput[];
}
export function useParsedPsbt({
allowedSighashes,
inputs,
inputsToSign,
outputs,
}: UseParsedPsbtArgs) {
const network = useCurrentNetwork();
const bitcoinAddressNativeSegwit = useCurrentAccountNativeSegwitIndexZeroSigner().address;
const { address: bitcoinAddressTaproot } = useCurrentAccountTaprootIndexZeroSigner();
const { isPsbtMutable, parsedInputs } = useParsedInputs({
allowedSighashes,
inputs,
inputsToSign,
});
const parsedOutputs = useParsedOutputs({ isPsbtMutable, outputs, network });
const {
inputsTotalNativeSegwit,
inputsTotalTaproot,
outputsTotalNativeSegwit,
outputsTotalTaproot,
psbtInputsTotal,
psbtOutputsTotal,
} = usePsbtTotals({
bitcoinAddressNativeSegwit,
bitcoinAddressTaproot,
parsedInputs,
parsedOutputs,
});
const { accountInscriptionsBeingTransferred, accountInscriptionsBeingReceived } =
usePsbtInscriptions(parsedInputs, parsedOutputs);
const defaultToAdvancedView = useCallback(() => {
const noInputs = !inputs.length;
const noOutputs = !outputs.length;
return noInputs || noOutputs;
}, [inputs.length, outputs.length]);
return {
accountInscriptionsBeingTransferred,
accountInscriptionsBeingReceived,
addressNativeSegwitTotal: subtractMoney(inputsTotalNativeSegwit, outputsTotalNativeSegwit),
addressTaprootTotal: subtractMoney(inputsTotalTaproot, outputsTotalTaproot),
fee: subtractMoney(psbtInputsTotal, psbtOutputsTotal),
isPsbtMutable,
psbtInputs: parsedInputs,
psbtOutputs: parsedOutputs,
shouldDefaultToAdvancedView: defaultToAdvancedView(),
};
}

View File

@@ -1,79 +0,0 @@
import { useCallback, useState } from 'react';
import * as btc from '@scure/btc-signer';
import { BitcoinNetworkModes } from '@shared/constants';
import { getAddressFromOutScript } from '@shared/crypto/bitcoin/bitcoin.utils';
import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
import { useCurrentNetwork } from '@app/store/networks/networks.selectors';
import {
PsbtDecodedUtxosMainnet,
PsbtDecodedUtxosTestnet,
usePsbtDecodedUtxos,
} from './use-psbt-decoded-utxos';
function isPlaceholderTransaction(
address: string,
network: BitcoinNetworkModes,
unsignedOutputs: btc.TransactionOutputRequired[],
unsignedUtxos: PsbtDecodedUtxosMainnet | PsbtDecodedUtxosTestnet
) {
let utxosNotFromCurrentAddress = [];
switch (unsignedUtxos.network) {
case 'mainnet':
utxosNotFromCurrentAddress = unsignedUtxos.utxos.filter(utxo => utxo.address !== address);
break;
case 'testnet':
utxosNotFromCurrentAddress = unsignedUtxos.utxos.filter(
vo => vo.scriptpubkey_address !== address
);
break;
}
const outputsNotToCurrentAddress = unsignedOutputs.filter(output => {
const addressFromScript = getAddressFromOutScript(output.script, network);
return addressFromScript !== address;
});
return utxosNotFromCurrentAddress.length === 0 && outputsNotToCurrentAddress.length === 0;
}
interface UsePsbtDecodedRequestArgs {
unsignedInputs: btc.TransactionInputRequired[];
unsignedOutputs: btc.TransactionOutputRequired[];
}
export function usePsbtDecodedRequest({
unsignedInputs,
unsignedOutputs,
}: UsePsbtDecodedRequestArgs) {
const [showAdvancedView, setShowAdvancedView] = useState(false);
const network = useCurrentNetwork();
const nativeSegwitSigner = useCurrentAccountNativeSegwitIndexZeroSigner();
const unsignedUtxos = usePsbtDecodedUtxos(unsignedInputs);
const defaultToAdvancedView = useCallback(() => {
const noInputs = !unsignedInputs.length;
const noOutputs = !unsignedOutputs.length;
return noInputs || noOutputs;
}, [unsignedInputs.length, unsignedOutputs.length]);
const showPlaceholder = useCallback(() => {
return isPlaceholderTransaction(
nativeSegwitSigner.address,
network.chain.bitcoin.network,
unsignedOutputs,
unsignedUtxos
);
}, [nativeSegwitSigner.address, network.chain.bitcoin.network, unsignedOutputs, unsignedUtxos]);
return {
onSetShowAdvancedView: () => setShowAdvancedView(!showAdvancedView),
shouldDefaultToAdvancedView: defaultToAdvancedView(),
shouldShowPlaceholder: showPlaceholder(),
showAdvancedView,
unsignedUtxos,
};
}

View File

@@ -1,44 +0,0 @@
import * as btc from '@scure/btc-signer';
import { WalletDefaultNetworkConfigurationIds } from '@shared/constants';
import { BitcoinTransactionVectorOutput } from '@shared/models/transactions/bitcoin-transaction.model';
import { isDefined } from '@shared/utils';
import {
OrdApiInscriptionTxOutput,
useOrdinalsAwareUtxoQueries,
} from '@app/query/bitcoin/ordinals/ordinals-aware-utxo.query';
import { TaprootUtxo } from '@app/query/bitcoin/ordinals/use-taproot-address-utxos.query';
import { useGetBitcoinTransactionQueries } from '@app/query/bitcoin/transaction/transaction.query';
import { useCurrentNetwork } from '@app/store/networks/networks.selectors';
export interface PsbtDecodedUtxosMainnet {
network: WalletDefaultNetworkConfigurationIds.mainnet;
utxos: (TaprootUtxo & OrdApiInscriptionTxOutput)[];
}
export interface PsbtDecodedUtxosTestnet {
network: WalletDefaultNetworkConfigurationIds.testnet;
utxos: BitcoinTransactionVectorOutput[];
}
export function usePsbtDecodedUtxos(
unsignedInputs: btc.TransactionInputRequired[]
): PsbtDecodedUtxosMainnet | PsbtDecodedUtxosTestnet {
const network = useCurrentNetwork();
const unsignedUtxos = useGetBitcoinTransactionQueries(unsignedInputs)
.map(query => query.data)
.filter(isDefined)
.map((input, i) => input.vout[unsignedInputs[i].index]);
// Mainnet only enabled query
const unsignedUtxosWithInscriptions = useOrdinalsAwareUtxoQueries(unsignedInputs)
.map(query => query.data)
.filter(isDefined);
return network.chain.bitcoin.network === 'mainnet' && unsignedUtxosWithInscriptions.length
? {
network: WalletDefaultNetworkConfigurationIds.mainnet,
utxos: unsignedUtxosWithInscriptions,
}
: { network: WalletDefaultNetworkConfigurationIds.testnet, utxos: unsignedUtxos };
}

View File

@@ -0,0 +1,52 @@
import { useMemo } from 'react';
import { isDefined } from '@shared/utils';
import { useGetInscriptionQueries } from '@app/query/bitcoin/ordinals/inscription.query';
import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
import { useCurrentAccountTaprootIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks';
import { findOutputsReceivingInscriptions } from './find-outputs-receiving-inscriptions';
import { PsbtInput } from './use-parsed-inputs';
import { PsbtOutput } from './use-parsed-outputs';
export function usePsbtInscriptions(psbtInputs: PsbtInput[], psbtOutputs: PsbtOutput[]) {
const bitcoinAddressNativeSegwit = useCurrentAccountNativeSegwitIndexZeroSigner().address;
const { address: bitcoinAddressTaproot } = useCurrentAccountTaprootIndexZeroSigner();
const inscriptions = useGetInscriptionQueries(
psbtInputs.map(utxo => utxo.inscription?.replace('/inscription/', '') ?? '')
)
.map(query => query.data)
.filter(isDefined);
const outputsReceivingInscriptions = useMemo(
() =>
findOutputsReceivingInscriptions({
inscriptions,
psbtInputs,
psbtOutputs,
}),
[inscriptions, psbtInputs, psbtOutputs]
);
return useMemo(
() => ({
accountInscriptionsBeingTransferred: psbtInputs
.filter(
input =>
input.address === bitcoinAddressNativeSegwit || input.address === bitcoinAddressTaproot
)
.map(input => input.inscription)
.filter(isDefined),
accountInscriptionsBeingReceived: outputsReceivingInscriptions
.filter(
outputWithInscription =>
outputWithInscription.address === bitcoinAddressNativeSegwit ||
outputWithInscription.address === bitcoinAddressTaproot
)
.map(input => input.inscription)
.filter(isDefined),
}),
[bitcoinAddressNativeSegwit, bitcoinAddressTaproot, outputsReceivingInscriptions, psbtInputs]
);
}

View File

@@ -9,12 +9,9 @@ import { isString } from '@shared/utils';
import { useCurrentAccountNativeSegwitSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
import { useCurrentAccountTaprootSigner } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks';
import { useSignPsbtError } from './use-sign-psbt-error';
export type DecodedPsbt = ReturnType<typeof btc.RawPSBTV0.decode>;
export type RawPsbt = ReturnType<typeof btc.RawPSBTV0.decode>;
export function usePsbtSigner() {
const signPsbtError = useSignPsbtError();
const createNativeSegwitSigner = useCurrentAccountNativeSegwitSigner();
const createTaprootSigner = useCurrentAccountTaprootSigner();
@@ -30,7 +27,7 @@ export function usePsbtSigner() {
try {
taprootSigner?.signIndex(tx, idx, allowedSighash);
} catch (e2) {
signPsbtError(`Error signing PSBT at provided index, ${e1}, ${e2}`);
throw new Error(`Unable to sign PSBT at provided index, ${e1 ?? e2}`);
}
}
},
@@ -41,9 +38,10 @@ export function usePsbtSigner() {
try {
taprootSigner?.sign(tx);
} catch (e2) {
signPsbtError(`Error signing PSBT, ${e1}, ${e2}`);
throw new Error(`Unable to sign PSBT, ${e1 ?? e2}`);
}
}
return;
},
getPsbtAsTransaction(psbt: string | Uint8Array) {
const bytes = isString(psbt) ? hexToBytes(psbt) : psbt;
@@ -53,17 +51,16 @@ export function usePsbtSigner() {
const bytes = isString(psbt) ? hexToBytes(psbt) : psbt;
try {
return btc.RawPSBTV0.decode(bytes);
} catch (e0) {
logger.error(`Failed to decode as PSBT v0, trying v2, ${e0}`);
} catch (e1) {
logger.error(`Unable to decode PSBT as v0, trying v2, ${e1}`);
try {
return btc.RawPSBTV2.decode(bytes);
} catch (e2) {
signPsbtError(`Failed to decode PSBT as v0 and v2, ${e0}, ${e2}`);
throw new Error(`Unable to decode PSBT, ${e1 ?? e2}`);
}
}
return;
},
}),
[nativeSegwitSigner, signPsbtError, taprootSigner]
[nativeSegwitSigner, taprootSigner]
);
}

View File

@@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { createMoney } from '@shared/models/money.model';
import { sumNumbers } from '@app/common/math/helpers';
import { PsbtInput } from './use-parsed-inputs';
import { PsbtOutput } from './use-parsed-outputs';
function calculateAddressInputsTotal(address: string, inputs: PsbtInput[]) {
return createMoney(
sumNumbers(inputs.filter(input => input.address === address).map(input => input.value)),
'BTC'
);
}
function calculateAddressOutputsTotal(address: string, outputs: PsbtOutput[]) {
return createMoney(
sumNumbers(
outputs.filter(output => output.address === address).map(output => Number(output.value))
),
'BTC'
);
}
function calculatePsbtInputsTotal(inputs: PsbtInput[]) {
return createMoney(sumNumbers(inputs.map(input => input.value)), 'BTC');
}
function calculatePsbtOutputsTotal(outputs: PsbtOutput[]) {
return createMoney(sumNumbers(outputs.map(output => output.value)), 'BTC');
}
interface UsePsbtTotalsProps {
bitcoinAddressNativeSegwit: string;
bitcoinAddressTaproot: string;
parsedInputs: PsbtInput[];
parsedOutputs: PsbtOutput[];
}
export function usePsbtTotals({
bitcoinAddressNativeSegwit,
bitcoinAddressTaproot,
parsedInputs,
parsedOutputs,
}: UsePsbtTotalsProps) {
return useMemo(
() => ({
inputsTotalNativeSegwit: calculateAddressInputsTotal(
bitcoinAddressNativeSegwit,
parsedInputs
),
inputsTotalTaproot: calculateAddressInputsTotal(bitcoinAddressTaproot, parsedInputs),
outputsTotalNativeSegwit: calculateAddressOutputsTotal(
bitcoinAddressNativeSegwit,
parsedOutputs
),
outputsTotalTaproot: calculateAddressOutputsTotal(bitcoinAddressTaproot, parsedOutputs),
psbtInputsTotal: calculatePsbtInputsTotal(parsedInputs),
psbtOutputsTotal: calculatePsbtOutputsTotal(parsedOutputs),
}),
[bitcoinAddressNativeSegwit, bitcoinAddressTaproot, parsedInputs, parsedOutputs]
);
}

View File

@@ -1,39 +0,0 @@
import { useCallback } from 'react';
import { RpcErrorCode } from '@btckit/types';
import { finalizePsbt } from '@shared/actions/finalize-psbt';
import { makeRpcErrorResponse } from '@shared/rpc/rpc-methods';
import {
usePsbtRequestSearchParams,
useRpcSignPsbtParams,
} from '@app/common/psbt/use-psbt-request-params';
export function useSignPsbtError() {
const { requestToken, tabId } = usePsbtRequestSearchParams();
const { requestId, tabId: rpcTabId } = useRpcSignPsbtParams();
return useCallback(
(errorMsg: string) => {
if (requestToken)
finalizePsbt({
requestPayload: requestToken,
tabId,
data: errorMsg,
});
chrome.tabs.sendMessage(
rpcTabId,
makeRpcErrorResponse('signPsbt', {
id: requestId,
error: {
message: errorMsg,
code: RpcErrorCode.INTERNAL_ERROR,
},
})
);
window.close();
},
[requestId, requestToken, rpcTabId, tabId]
);
}

View File

@@ -1,22 +1,28 @@
import * as btc from '@scure/btc-signer';
import { useRouteHeader } from '@app/common/hooks/use-route-header';
import { PopupHeader } from '@app/features/current-account/popup-header';
import { useOnOriginTabClose } from '@app/routes/hooks/use-on-tab-closed';
import { PsbtDecodedRequest } from './components/psbt-decoded-request/psbt-decoded-request';
import { PsbtRequestActions } from './components/psbt-request-actions';
import { PsbtRequestDetails } from './components/psbt-request-details/psbt-request-details';
import { PsbtRequestHeader } from './components/psbt-request-header';
import { PsbtRequestAppWarningLabel } from './components/psbt-request-warning-label';
import { PsbtRequestLayout } from './components/psbt-request.layout';
import { DecodedPsbt } from './hooks/use-psbt-signer';
import { PsbtSignerLayout } from './components/psbt-signer.layout';
import { RawPsbt } from './hooks/use-psbt-signer';
interface PsbtSignerProps {
appName: string;
psbt: DecodedPsbt;
allowedSighashes?: btc.SignatureHash[];
inputsToSign?: number | number[];
name?: string;
origin: string;
onCancel(): void;
onSignPsbt(): void;
psbtRaw: RawPsbt;
psbtTx: btc.Transaction;
}
export function PsbtSigner(props: PsbtSignerProps) {
const { appName, psbt, onCancel, onSignPsbt } = props;
const { allowedSighashes, inputsToSign, name, origin, onCancel, onSignPsbt, psbtRaw, psbtTx } =
props;
useRouteHeader(<PopupHeader displayAddresssBalanceOf="all" />);
@@ -24,11 +30,15 @@ export function PsbtSigner(props: PsbtSignerProps) {
return (
<>
<PsbtRequestLayout>
<PsbtRequestHeader origin={appName} />
<PsbtRequestAppWarningLabel appName={appName} />
<PsbtDecodedRequest psbt={psbt} />
</PsbtRequestLayout>
<PsbtSignerLayout>
<PsbtRequestHeader name={name} origin={origin} />
<PsbtRequestDetails
allowedSighashes={allowedSighashes}
inputsToSign={inputsToSign}
psbtRaw={psbtRaw}
psbtTx={psbtTx}
/>
</PsbtSignerLayout>
<PsbtRequestActions isLoading={false} onCancel={onCancel} onSignPsbt={onSignPsbt} />
</>
);

View File

@@ -4,16 +4,29 @@ import { PsbtSigner } from '@app/features/psbt-signer/psbt-signer';
import { usePsbtRequest } from './use-psbt-request';
export function PsbtRequest() {
const { appName, isLoading, decodedPsbt, onSignPsbt, onDenyPsbtSigning } = usePsbtRequest();
const {
appName,
isLoading,
decodedPsbt,
onSignPsbt,
onDenyPsbtSigning,
origin,
psbtPayload,
tx,
} = usePsbtRequest();
if (isLoading || !decodedPsbt) return <LoadingSpinner height="600px" />;
return (
<PsbtSigner
appName={appName ?? ''}
psbt={decodedPsbt}
allowedSighashes={psbtPayload.allowedSighash}
name={appName ?? ''}
inputsToSign={psbtPayload.signAtIndex}
origin={origin ?? ''}
onSignPsbt={onSignPsbt}
onCancel={onDenyPsbtSigning}
psbtRaw={decodedPsbt}
psbtTx={tx}
/>
);
}

View File

@@ -1,8 +1,10 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
import { finalizePsbt } from '@shared/actions/finalize-psbt';
import { RouteUrls } from '@shared/route-urls';
import { ensureArray, isUndefined } from '@shared/utils';
import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
@@ -11,10 +13,14 @@ import { usePsbtRequestSearchParams } from '@app/common/psbt/use-psbt-request-pa
import { usePsbtSigner } from '@app/features/psbt-signer/hooks/use-psbt-signer';
export function usePsbtRequest() {
const { requestToken, tabId } = usePsbtRequestSearchParams();
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const { origin, requestToken, tabId } = usePsbtRequestSearchParams();
const { signPsbt, signPsbtAtIndex, getDecodedPsbt, getPsbtAsTransaction } = usePsbtSigner();
const analytics = useAnalytics();
return useMemo(() => {
if (!requestToken) throw new Error('Cannot decode psbt without request token');
@@ -25,16 +31,27 @@ export function usePsbtRequest() {
return {
appName,
isLoading,
psbtPayload: payload,
getDecodedPsbt,
origin,
psbtPayload: payload,
get decodedPsbt() {
return getDecodedPsbt(payload.hex);
try {
return getDecodedPsbt(payload.hex);
} catch (e) {
return navigate(RouteUrls.RequestError, {
state: { message: e instanceof Error ? e.message : '', title: 'Failed request' },
});
}
},
tx: getPsbtAsTransaction(payload.hex),
payloadTxBytes,
onDenyPsbtSigning() {
void analytics.track('request_psbt_cancel');
finalizePsbt({ requestPayload: requestToken, tabId, data: 'PSBT request was canceled' });
finalizePsbt({
data: 'PSBT request was canceled',
requestPayload: requestToken,
tabId,
});
},
onSignPsbt() {
setIsLoading(true);
@@ -45,22 +62,26 @@ export function usePsbtRequest() {
const indexOrIndexes = payload?.signAtIndex;
const allowedSighash = payload?.allowedSighash;
if (!isUndefined(indexOrIndexes)) {
ensureArray(indexOrIndexes).forEach(idx => signPsbtAtIndex(idx, tx, allowedSighash));
} else {
signPsbt(tx);
try {
if (!isUndefined(indexOrIndexes)) {
ensureArray(indexOrIndexes).forEach(idx => signPsbtAtIndex(idx, tx, allowedSighash));
} else {
signPsbt(tx);
}
} catch (e) {
return navigate(RouteUrls.RequestError, {
state: { message: e instanceof Error ? e.message : '', title: 'Failed to sign' },
});
}
const psbt = tx.toPSBT();
setIsLoading(false);
if (!requestToken) return;
finalizePsbt({
data: { hex: bytesToHex(psbt) },
requestPayload: requestToken,
tabId,
data: { hex: bytesToHex(psbt) },
});
},
};
@@ -69,6 +90,8 @@ export function usePsbtRequest() {
getDecodedPsbt,
getPsbtAsTransaction,
isLoading,
navigate,
origin,
requestToken,
signPsbt,
signPsbtAtIndex,

View File

@@ -0,0 +1,26 @@
import { useLocation } from 'react-router-dom';
import { Box, Text } from '@stacks/ui';
import get from 'lodash.get';
import { GenericError } from '@app/components/generic-error/generic-error';
const helpTextList = [
<Box as="li" mt="base" key={1}>
<Text>Please report issue to requesting app</Text>
</Box>,
];
function useRequestErrorState() {
const location = useLocation();
const message = get(location.state, 'message') as string;
const title = get(location.state, 'title') as string;
return { message, title };
}
export function RequestError() {
const { message, title } = useRequestErrorState();
return <GenericError body={message} helpTextList={helpTextList} title={title} />;
}

View File

@@ -1,6 +1,9 @@
import { useNavigate } from 'react-router-dom';
import { RpcErrorCode } from '@btckit/types';
import { bytesToHex } from '@noble/hashes/utils';
import { RouteUrls } from '@shared/route-urls';
import { makeRpcErrorResponse, makeRpcSuccessResponse } from '@shared/rpc/rpc-methods';
import { isDefined } from '@shared/utils';
@@ -9,15 +12,27 @@ import { usePsbtSigner } from '@app/features/psbt-signer/hooks/use-psbt-signer';
import { PsbtSigner } from '@app/features/psbt-signer/psbt-signer';
function useRpcSignPsbt() {
const navigate = useNavigate();
const { origin, tabId, requestId, psbtHex, allowedSighash, signAtIndex } = useRpcSignPsbtParams();
const { signPsbt, signPsbtAtIndex, getDecodedPsbt, getPsbtAsTransaction } = usePsbtSigner();
if (!requestId || !psbtHex || !origin) throw new Error('Invalid params');
const tx = getPsbtAsTransaction(psbtHex);
return {
allowedSighashes: allowedSighash,
inputsToSign: signAtIndex,
origin,
tx,
get decodedPsbt() {
return getDecodedPsbt(psbtHex);
try {
return getDecodedPsbt(psbtHex);
} catch (e) {
return navigate(RouteUrls.RequestError, {
state: { message: e instanceof Error ? e.message : '', title: 'Failed request' },
});
}
},
onSignPsbt() {
if (isDefined(signAtIndex)) {
@@ -52,9 +67,18 @@ function useRpcSignPsbt() {
}
export function RpcSignPsbt() {
const { origin, decodedPsbt, onSignPsbt, onCancel } = useRpcSignPsbt();
const { allowedSighashes, origin, decodedPsbt, inputsToSign, onSignPsbt, onCancel, tx } =
useRpcSignPsbt();
if (!decodedPsbt) return null;
return (
<PsbtSigner appName={origin} onSignPsbt={onSignPsbt} onCancel={onCancel} psbt={decodedPsbt} />
<PsbtSigner
allowedSighashes={allowedSighashes}
inputsToSign={inputsToSign}
origin={origin}
onSignPsbt={onSignPsbt}
onCancel={onCancel}
psbtRaw={decodedPsbt}
psbtTx={tx}
/>
);
}

View File

@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { useQueries, useQuery } from '@tanstack/react-query';
import { Inscription } from '@shared/models/inscription.model';
@@ -36,3 +36,16 @@ export function useGetInscriptionQuery<T extends unknown = FetchInscriptionResp>
...options,
});
}
export function useGetInscriptionQueries(ids: string[]) {
return useQueries({
queries: ids.map(id => {
return {
enabled: !!id,
queryKey: [QueryPrefixes.InscriptionMetadata, id],
queryFn: () => fetchInscription()(id),
...inscriptionQueryOptions,
};
}),
});
}

View File

@@ -3,7 +3,7 @@ import { bytesToHex } from '@stacks/common';
import { useQueries } from '@tanstack/react-query';
import * as yup from 'yup';
import { isTypedArray } from '@shared/utils';
import { isDefined, isTypedArray } from '@shared/utils';
import { Prettify } from '@shared/utils/type-utils';
import { QueryPrefixes } from '@app/query/query-prefixes';
@@ -25,7 +25,7 @@ const ordApiGetTransactionOutput = yup
})
.required();
export type OrdApiInscriptionTxOutput = Prettify<yup.InferType<typeof ordApiGetTransactionOutput>>;
type OrdApiInscriptionTxOutput = Prettify<yup.InferType<typeof ordApiGetTransactionOutput>>;
export async function getNumberOfInscriptionOnUtxo(id: string, index: number) {
const resp = await fetchOrdinalsAwareUtxo(id, index);
@@ -34,6 +34,12 @@ export async function getNumberOfInscriptionOnUtxo(id: string, index: number) {
return 0;
}
function getQueryArgsWithDefaults(utxo: TaprootUtxo | btc.TransactionInput) {
const txId = isTypedArray(utxo.txid) ? bytesToHex(utxo.txid) : utxo.txid ?? '';
const txIndex = 'vout' in utxo ? utxo.vout : utxo.index ?? 0;
return { txId, txIndex };
}
async function fetchOrdinalsAwareUtxo(
txid: string,
index: number
@@ -48,7 +54,7 @@ async function fetchOrdinalsAwareUtxo(
return ordApiGetTransactionOutput.validate(data);
}
function makeOrdinalsAwareUtxoQueryKey(txId: string, txIndex: number) {
function makeOrdinalsAwareUtxoQueryKey(txId: string, txIndex?: number) {
return [QueryPrefixes.InscriptionFromTxid, txId, txIndex] as const;
}
@@ -58,12 +64,12 @@ const queryOptions = {
refetchOnWindowFocus: false,
} as const;
export function useOrdinalsAwareUtxoQueries(utxos: TaprootUtxo[] | btc.TransactionInputRequired[]) {
export function useOrdinalsAwareUtxoQueries(utxos: TaprootUtxo[] | btc.TransactionInput[]) {
return useQueries({
queries: utxos.map(utxo => {
const txId = isTypedArray(utxo.txid) ? bytesToHex(utxo.txid) : utxo.txid;
const txIndex = 'index' in utxo ? utxo.index : utxo.vout;
const { txId, txIndex } = getQueryArgsWithDefaults(utxo);
return {
enable: txId !== '' && isDefined(txIndex),
queryKey: makeOrdinalsAwareUtxoQueryKey(txId, txIndex),
queryFn: () => fetchOrdinalsAwareUtxo(txId, txIndex),
select: (resp: OrdApiInscriptionTxOutput) =>

View File

@@ -40,14 +40,15 @@ const queryOptions = {
refetchOnWindowFocus: false,
} as const;
// ts-unused-exports:disable-next-line
export function useGetBitcoinTransactionQueries(
inputs: btc.TransactionInputRequired[]
inputs: btc.TransactionInput[]
): UseQueryResult<BitcoinTx>[] {
const client = useBitcoinClient();
return useQueries({
queries: inputs.map(input => {
const txId = bytesToHex(input.txid);
const txId = input.txid ? bytesToHex(input.txid) : '';
return {
queryKey: ['bitcoin-transaction', txId],
queryFn: () => fetchBitcoinTransaction(client)(txId),

View File

@@ -40,6 +40,7 @@ import { ReceiveModal } from '@app/pages/receive-tokens/receive-modal';
import { ReceiveStxModal } from '@app/pages/receive-tokens/receive-stx';
import { ReceiveCollectibleModal } from '@app/pages/receive/receive-collectible/receive-collectible-modal';
import { ReceiveCollectibleOrdinal } from '@app/pages/receive/receive-collectible/receive-collectible-oridinal';
import { RequestError } from '@app/pages/request-error/request-error';
import { RpcGetAddresses } from '@app/pages/rpc-get-addresses/rpc-get-addresses';
import { rpcSendTransferRoutes } from '@app/pages/rpc-send-transfer/rpc-send-transfer.routes';
import { RpcSignPsbt } from '@app/pages/rpc-sign-psbt/rpc-sign-psbt';
@@ -90,6 +91,88 @@ function useAppRoutes() {
</Route>
);
const legacyRequestRoutes = (
<>
<Route
path={RouteUrls.TransactionRequest}
element={
<AccountGate>
<Suspense fallback={<LoadingSpinner height="600px" />}>
<TransactionRequest />
</Suspense>
</AccountGate>
}
>
{ledgerStacksTxSigningRoutes}
<Route path={RouteUrls.EditNonce} element={<EditNonceDrawer />} />
<Route path={RouteUrls.TransactionBroadcastError} element={<BroadcastErrorDrawer />} />
</Route>
<Route
path={RouteUrls.SignatureRequest}
element={
<AccountGate>
<Suspense fallback={<LoadingSpinner height="600px" />}>
<StacksMessageSigningRequest />
</Suspense>
</AccountGate>
}
>
{ledgerStacksMessageSigningRoutes}
</Route>
<Route
path={RouteUrls.ProfileUpdateRequest}
element={
<AccountGate>
<Suspense fallback={<LoadingSpinner height="600px" />}>
<ProfileUpdateRequest />
</Suspense>
</AccountGate>
}
/>
<Route
path={RouteUrls.PsbtRequest}
element={
<AccountGate>
<Suspense fallback={<LoadingSpinner height="600px" />}>
<PsbtRequest />
</Suspense>
</AccountGate>
}
/>
</>
);
const rpcRequestRoutes = (
<>
<Route
path={RouteUrls.RpcGetAddresses}
element={
<AccountGate>
<RpcGetAddresses />
</AccountGate>
}
/>
{rpcSendTransferRoutes}
<Route
path={RouteUrls.RpcSignBip322Message}
lazy={async () => {
const { RpcSignBip322MessageRoute } = await import(
'@app/pages/rpc-sign-bip322-message/rpc-sign-bip322-message'
);
return { Component: RpcSignBip322MessageRoute };
}}
/>
<Route
path={RouteUrls.RpcSignPsbt}
element={
<AccountGate>
<RpcSignPsbt />
</AccountGate>
}
/>
</>
);
return createHashRouter(
createRoutesFromElements(
<Route path={RouteUrls.Container} element={<Container />}>
@@ -228,55 +311,6 @@ function useAppRoutes() {
{sendCryptoAssetFormRoutes}
<Route
path={RouteUrls.TransactionRequest}
element={
<AccountGate>
<Suspense fallback={<LoadingSpinner height="600px" />}>
<TransactionRequest />
</Suspense>
</AccountGate>
}
>
{ledgerStacksTxSigningRoutes}
<Route path={RouteUrls.EditNonce} element={<EditNonceDrawer />} />
<Route path={RouteUrls.TransactionBroadcastError} element={<BroadcastErrorDrawer />} />
</Route>
<Route path={RouteUrls.UnauthorizedRequest} element={<UnauthorizedRequest />} />
<Route
path={RouteUrls.SignatureRequest}
element={
<AccountGate>
<Suspense fallback={<LoadingSpinner height="600px" />}>
<StacksMessageSigningRequest />
</Suspense>
</AccountGate>
}
>
{ledgerStacksMessageSigningRoutes}
</Route>
<Route
path={RouteUrls.ProfileUpdateRequest}
element={
<AccountGate>
<Suspense fallback={<LoadingSpinner height="600px" />}>
<ProfileUpdateRequest />
</Suspense>
</AccountGate>
}
/>
<Route
path={RouteUrls.PsbtRequest}
element={
<AccountGate>
<Suspense fallback={<LoadingSpinner height="600px" />}>
<PsbtRequest />
</Suspense>
</AccountGate>
}
/>
<Route
path={RouteUrls.ViewSecretKey}
element={
@@ -291,34 +325,17 @@ function useAppRoutes() {
{settingsModalRoutes}
</Route>
{legacyRequestRoutes}
{rpcRequestRoutes}
<Route path={RouteUrls.UnauthorizedRequest} element={<UnauthorizedRequest />} />
<Route
path={RouteUrls.RpcGetAddresses}
path={RouteUrls.RequestError}
element={
<AccountGate>
<RpcGetAddresses />
<RequestError />
</AccountGate>
}
/>
{rpcSendTransferRoutes}
<Route
path={RouteUrls.RpcSignPsbt}
element={
<AccountGate>
<RpcSignPsbt />
</AccountGate>
}
/>
<Route
path={RouteUrls.RpcSignBip322Message}
lazy={async () => {
const { RpcSignBip322MessageRoute } = await import(
'@app/pages/rpc-sign-bip322-message/rpc-sign-bip322-message'
);
return { Component: RpcSignBip322MessageRoute };
}}
/>
{/* Catch-all route redirects to onboarding */}
<Route path="*" element={<Navigate replace to={RouteUrls.Onboarding} />} />

View File

@@ -85,13 +85,13 @@ export function bitcoinAddressIndexSignerFactory<T extends BitcoinAddressIndexSi
paymentFn,
signFn(tx: btc.Transaction) {
if (!addressIndexKeychain.privateKey)
throw new Error('Unable to sign taproot transaction, no private key found');
throw new Error('Unable to sign transaction, no private key found');
tx.sign(addressIndexKeychain.privateKey);
},
signAtIndexFn(tx: btc.Transaction, index: number, allowedSighash?: btc.SignatureHash[]) {
if (!addressIndexKeychain.privateKey)
throw new Error('Unable to sign taproot transaction, no private key found');
throw new Error('Unable to sign transaction, no private key found');
tx.signIdx(addressIndexKeychain.privateKey, index, allowedSighash);
},

View File

@@ -21,11 +21,11 @@ export function formatPsbtResponse({
}
interface FinalizePsbtArgs {
data: PsbtData | string;
requestPayload: string;
tabId: number;
data: PsbtData | string;
}
export function finalizePsbt({ requestPayload, data, tabId }: FinalizePsbtArgs) {
export function finalizePsbt({ data, requestPayload, tabId }: FinalizePsbtArgs) {
const responseMessage = formatPsbtResponse({ request: requestPayload, response: data });
chrome.tabs.sendMessage(tabId, responseMessage);
window.close();

View File

@@ -2,14 +2,12 @@ import { PaymentTypes } from '@btckit/types';
import { hexToBytes } from '@noble/hashes/utils';
import { HDKey, Versions } from '@scure/bip32';
import * as btc from '@scure/btc-signer';
import * as P from 'micro-packed';
import { BitcoinNetworkModes, NetworkModes } from '@shared/constants';
import { logger } from '@shared/logger';
import { whenNetwork } from '@shared/utils';
import { DerivationPathDepth } from '../derivation-path.utils';
import { BtcSignerNetwork, getBtcSignerLibNetworkConfigByMode } from './bitcoin.network';
import { BtcSignerNetwork } from './bitcoin.network';
export interface BitcoinAccount {
type: PaymentTypes;
@@ -56,43 +54,45 @@ export function ecdsaPublicKeyToSchnorr(pubKey: Uint8Array) {
return pubKey.slice(1);
}
// basically same as above, to remov
// Basically same as above, to remove
export const toXOnly = (pubKey: Buffer) => (pubKey.length === 32 ? pubKey : pubKey.slice(1, 33));
export function decodeBitcoinTx(tx: string) {
return btc.RawTx.decode(hexToBytes(tx));
}
const concat = P.concatBytes;
export function getAddressFromOutScript(script: Uint8Array, bitcoinNetwork: BtcSignerNetwork) {
const outputScript = btc.OutScript.decode(script);
function formatKey(hashed: Uint8Array, prefix: number[]): string {
return btc.base58check.encode(concat(Uint8Array.from(prefix), hashed));
}
function getAddressFromWshOutScript(script: Uint8Array, network: BtcSignerNetwork) {
return btc.programToWitness(0, script.slice(2), network);
}
function getAddressFromWpkhOutScript(script: Uint8Array, network: BtcSignerNetwork) {
return btc.programToWitness(0, script.slice(2), network);
}
function getAddressFromTrOutScript(script: Uint8Array, network: BtcSignerNetwork) {
return btc.programToWitness(1, script.slice(2), network);
}
export function getAddressFromOutScript(script: Uint8Array, network: BitcoinNetworkModes) {
const outScript = btc.OutScript.decode(script);
// This appears to be undefined at times?
const bitcoinNetwork = getBtcSignerLibNetworkConfigByMode(network);
if (outScript.type === 'wsh') return getAddressFromWshOutScript(script, bitcoinNetwork);
else if (outScript.type === 'wpkh') return getAddressFromWpkhOutScript(script, bitcoinNetwork);
else if (outScript.type === 'tr') return getAddressFromTrOutScript(script, bitcoinNetwork);
else if (outScript.type === 'pkh') return formatKey(script, [bitcoinNetwork?.pubKeyHash]);
else if (outScript.type === 'sh') return formatKey(script, [bitcoinNetwork?.scriptHash]);
logger.error(`Unknown address type=${outScript.type}`);
return '';
if (outputScript.type === 'pk' || outputScript.type === 'tr') {
return btc.Address(bitcoinNetwork).encode({
type: outputScript.type,
pubkey: outputScript.pubkey,
});
}
if (outputScript.type === 'ms' || outputScript.type === 'tr_ms') {
return btc.Address(bitcoinNetwork).encode({
type: outputScript.type,
pubkeys: outputScript.pubkeys,
m: outputScript.m,
});
}
if (outputScript.type === 'tr_ns') {
return btc.Address(bitcoinNetwork).encode({
type: outputScript.type,
pubkeys: outputScript.pubkeys,
});
}
if (outputScript.type === 'unknown') {
return btc.Address(bitcoinNetwork).encode({
type: outputScript.type,
script,
});
}
return btc.Address(bitcoinNetwork).encode({
type: outputScript.type,
hash: outputScript.hash,
});
}
type BtcSignerLibPaymentTypeIdentifers = 'wpkh' | 'wsh' | 'tr' | 'pkh' | 'sh';

View File

@@ -1,25 +1,33 @@
export interface InscriptionResponseItem {
id: string;
number: number;
address: string;
genesis_address: string;
genesis_block_height: number;
genesis_block_hash: string;
genesis_tx_id: string;
content: string;
content_length: string;
content_type: string;
genesis_fee: string;
genesis_timestamp: number;
tx_id: string;
genesis_height: string;
genesis_transaction: string;
id: string;
inscription_number: number;
location: string;
output: string;
value: string;
offset: string;
output: string;
output_value: string;
preview: string;
sat: string;
timestamp: string;
// Outdated props?
genesis_address: string;
genesis_block_hash: string;
genesis_block_height: number;
genesis_timestamp: number;
genesis_tx_id: string;
mime_type: string;
number: number;
sat_coinbase_height: number;
sat_ordinal: string;
sat_rarity: string;
sat_coinbase_height: number;
mime_type: string;
content_type: string;
content_length: number;
timestamp: number;
tx_id: string;
value: string;
}
export interface Inscription extends InscriptionResponseItem {

View File

@@ -25,7 +25,7 @@ interface BitcoinTransactionStatus {
block_time?: number | null;
}
export interface BitcoinTransactionVectorOutput {
interface BitcoinTransactionVectorOutput {
scriptpubkey: string;
scriptpubkey_asm: string;
scriptpubkey_type: string;

View File

@@ -44,14 +44,6 @@ export enum RouteUrls {
Send = '/send-transaction',
ViewSecretKey = '/view-secret-key',
// App requests (legacy)
ProfileUpdateRequest = '/update-profile',
PsbtRequest = '/psbt',
SignatureRequest = '/signature',
TransactionRequest = '/transaction',
TransactionBroadcastError = 'broadcast-error',
UnauthorizedRequest = '/unauthorized-request',
// Locked wallet route
Unlock = '/unlock',
@@ -93,7 +85,14 @@ export enum RouteUrls {
SendOrdinalInscriptionSent = '/send/ordinal-inscription/sent',
SendOrdinalInscriptionError = '/send/ordinal-inscription/error',
// Request routes
// Legacy request routes
ProfileUpdateRequest = '/update-profile',
PsbtRequest = '/psbt',
SignatureRequest = '/signature',
TransactionRequest = '/transaction',
TransactionBroadcastError = 'broadcast-error',
// Rpc request routes
RpcGetAddresses = '/get-addresses',
RpcSignPsbt = '/sign-psbt',
RpcSendTransfer = '/send-transfer',
@@ -102,4 +101,8 @@ export enum RouteUrls {
RpcSendTransferSummary = '/send-transfer/summary',
RpcReceiveBitcoinContractOffer = '/bitcoin-contract-offer/:bitcoinContractOffer/:counterpartyWalletURL',
RpcSignBip322Message = '/sign-bip322-message',
// Shared legacy and rpc request routes
RequestError = '/request-error',
UnauthorizedRequest = '/unauthorized-request',
}

View File

@@ -84,6 +84,7 @@ function buildTestNativeSegwitPsbtRequestWithIndexes(pubKey: Uint8Array): PsbtRe
tx.addInput({
index: 0,
txid: '5be910a6557bae29b8ff2dbf4607dbf783eaf82802896d13f61d975c133ccce7',
sighashType: 1,
witnessUtxo: {
amount: BigInt(1268294),
script: p2wpkh.script,
@@ -104,7 +105,7 @@ function buildTestNativeSegwitPsbtRequestWithIndexes(pubKey: Uint8Array): PsbtRe
const psbt = tx.toPSBT();
return { signAtIndex: [0, 1], hex: bytesToHex(psbt) };
return { signAtIndex: [0, 1], hex: bytesToHex(psbt), allowedSighash: [2] };
}
function buildTestTaprootPsbtRequest(pubKey: Uint8Array): PsbtRequestOptions {

169
tests/mocks/mock-psbts.ts Normal file
View File

@@ -0,0 +1,169 @@
export const mockInscriptions1 = [
{
address: 'bc1pwrmewwprc8k8l2k63x4advg0nx0jk50xzqnee996lm87mcuza7kq6drg2k',
addressIndex: 0,
content: '/content/ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202i0',
content_length: '519 bytes',
content_type: 'image/png',
genesis_fee: '11130',
genesis_height: '/block/792967',
genesis_transaction: '/tx/ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202',
id: 'ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202i0',
inscription_number: 10875335,
location: 'ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202:0:0',
offset: '200',
output: '/output/ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202:0',
output_value: '10000',
preview: '/preview/ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202i0',
sat: '/sat/516510429895340',
timestamp: '2023-06-05 13:28:12 UTC',
title: 'Inscription 10875335',
// Outdated props?
genesis_address: '',
genesis_block_hash: '',
genesis_block_height: 0,
genesis_timestamp: 0,
genesis_tx_id: '',
mime_type: '',
number: 0,
sat_coinbase_height: 0,
sat_ordinal: '',
sat_rarity: '',
tx_id: '',
value: '',
},
];
export const mockPsbtInputs1 = [
{
address: 'bc1qvcfy9yxjl3303jurcrs4sd49frjrmjk7x045r6',
index: 0,
mutable: false,
sign: true,
txid: 'c7bb536fc3a645c8c9bad70a651f997b10c99ac8bf8c8c34229cd3895bc96f3f',
value: 600,
},
{
address: 'bc1qvcfy9yxjl3303jurcrs4sd49frjrmjk7x045r6',
index: 1,
mutable: false,
sign: true,
txid: 'c7bb536fc3a645c8c9bad70a651f997b10c99ac8bf8c8c34229cd3895bc96f3f',
value: 600,
},
{
address: 'bc1pwrmewwprc8k8l2k63x4advg0nx0jk50xzqnee996lm87mcuza7kq6drg2k',
index: 0,
inscription: '/inscription/ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202i0',
mutable: true,
sign: false,
txid: 'ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202',
value: 10000,
},
{
address: 'bc1qvcfy9yxjl3303jurcrs4sd49frjrmjk7x045r6',
index: 2,
mutable: false,
sign: true,
txid: 'c7bb536fc3a645c8c9bad70a651f997b10c99ac8bf8c8c34229cd3895bc96f3f',
value: 96794,
},
];
export const mockPsbtOutputs1 = [
{
address: 'bc1qvcfy9yxjl3303jurcrs4sd49frjrmjk7x045r6',
mutable: true,
sign: true,
value: 1200,
},
{
address: 'bc1p9pnzvq52956jht5deha82qp96pxw0a0tvey6fhdea7vwhf33tarskqq3nr',
mutable: true,
sign: true,
value: 10000,
},
{
address: '3NLVhyKWGey73pVbFAZ28nNVZB22upWZWq',
mutable: true,
sign: false,
value: 87220,
},
{
address: 'bc1qyylrgsxjrmaearjqqradhy8ferh4u0ydw4yuze',
mutable: true,
sign: false,
value: 1780,
},
];
export const mockInscriptions2 = [
{
address: 'bc1pwrmewwprc8k8l2k63x4advg0nx0jk50xzqnee996lm87mcuza7kq6drg2k',
addressIndex: 0,
content: '/content/ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202i0',
content_length: '519 bytes',
content_type: 'image/png',
genesis_fee: '11130',
genesis_height: '/block/792967',
genesis_transaction: '/tx/ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202',
id: 'ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202i0',
inscription_number: 10875335,
location: 'ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202:0:0',
offset: '600',
output: '/output/ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202:0',
output_value: '10000',
preview: '/preview/ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202i0',
sat: '/sat/516510429895340',
timestamp: '2023-06-05 13:28:12 UTC',
title: 'Inscription 10875335',
// Outdated props?
genesis_address: '',
genesis_block_hash: '',
genesis_block_height: 0,
genesis_timestamp: 0,
genesis_tx_id: '',
mime_type: '',
number: 0,
sat_coinbase_height: 0,
sat_ordinal: '',
sat_rarity: '',
tx_id: '',
value: '',
},
];
export const mockPsbtInputs2 = [
{
address: 'bc1qvcfy9yxjl3303jurcrs4sd49frjrmjk7x045r6',
index: 0,
mutable: false,
sign: true,
txid: 'c7bb536fc3a645c8c9bad70a651f997b10c99ac8bf8c8c34229cd3895bc96f3f',
value: 100,
},
{
address: 'bc1pwrmewwprc8k8l2k63x4advg0nx0jk50xzqnee996lm87mcuza7kq6drg2k',
index: 0,
inscription: '/inscription/ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202i0',
mutable: true,
sign: false,
txid: 'ba39f922074c0d338a13ac10e770a5da47ce09df8310c8d3cfaec13a347e8202',
value: 1000,
},
];
export const mockPsbtOutputs2 = [
{
address: 'bc1qvcfy9yxjl3303jurcrs4sd49frjrmjk7x045r6',
mutable: true,
sign: true,
value: 400,
},
{
address: 'bc1p9pnzvq52956jht5deha82qp96pxw0a0tvey6fhdea7vwhf33tarskqq3nr',
mutable: true,
sign: true,
value: 500,
},
];