diff --git a/src/Popup/Routes/index.tsx b/src/Popup/Routes/index.tsx index 67960a0eb..9a21be9d9 100644 --- a/src/Popup/Routes/index.tsx +++ b/src/Popup/Routes/index.tsx @@ -39,6 +39,10 @@ import Dashboard from '~/Popup/pages/Dashboard'; import Home from '~/Popup/pages/Home'; import PopupAptosSignMessage from '~/Popup/pages/Popup/Aptos/SignMessage'; import PopupAptosTransaction from '~/Popup/pages/Popup/Aptos/Transaction'; +import PopupBitcoinSend from '~/Popup/pages/Popup/Bitcoin/Send'; +import PopupBitcoinSignPsbt from '~/Popup/pages/Popup/Bitcoin/SignPsbt'; +// import PopupBitcoinSignMessage from '~/Popup/pages/Popup/Bitcoin/SignMessage'; +import PopupBitcoinSwitchNetwork from '~/Popup/pages/Popup/Bitcoin/SwitchNetwork'; import PopupCosmosAddChain from '~/Popup/pages/Popup/Cosmos/AddChain'; import PopupCosmosAddNFTs from '~/Popup/pages/Popup/Cosmos/AddNFTs'; import PopupCosmosAddTokens from '~/Popup/pages/Popup/Cosmos/AddTokens'; @@ -172,6 +176,11 @@ export default function Routes() { } /> } /> + } /> + } /> + } /> + {/* } /> */} + }> } /> } /> diff --git a/src/Popup/background/index.ts b/src/Popup/background/index.ts index 339ea5f0b..8491b198f 100644 --- a/src/Popup/background/index.ts +++ b/src/Popup/background/index.ts @@ -1,5 +1,6 @@ import { APTOS_NETWORKS, ETHEREUM_NETWORKS, SUI_NETWORKS } from '~/constants/chain'; import { APTOS } from '~/constants/chain/aptos/aptos'; +import { BITCOIN } from '~/constants/chain/bitcoin/bitcoin'; import { SIGNET } from '~/constants/chain/bitcoin/signet'; import { COSMOS } from '~/constants/chain/cosmos/cosmos'; import { ETHEREUM } from '~/constants/chain/ethereum/ethereum'; @@ -135,6 +136,7 @@ function background() { await setStorage('selectedEthereumNetworkId', ETHEREUM_NETWORKS[0].id); await setStorage('selectedAptosNetworkId', APTOS_NETWORKS[0].id); await setStorage('selectedSuiNetworkId', SUI_NETWORKS[0].id); + await setStorage('selectedBitcoinChainId', BITCOIN.id); await setStorage('address', {}); diff --git a/src/Popup/background/messageProcessor.ts b/src/Popup/background/messageProcessor.ts index e7f086f22..b96f321e4 100644 --- a/src/Popup/background/messageProcessor.ts +++ b/src/Popup/background/messageProcessor.ts @@ -1,3 +1,5 @@ +import validate from 'bitcoin-address-validation'; +import { Transaction } from 'bitcoinjs-lib'; import encHex from 'crypto-js/enc-hex'; import sha256 from 'crypto-js/sha256'; import { debounce } from 'lodash'; @@ -9,6 +11,7 @@ import { keccak256 } from '@ethersproject/keccak256'; import type { MessageTypes } from '@metamask/eth-sig-util'; import { SignTypedDataVersion } from '@metamask/eth-sig-util'; +import { Network } from '~/constants/bitcoin'; import { COSMOS_CHAINS, ETHEREUM_NETWORKS } from '~/constants/chain'; import { APTOS } from '~/constants/chain/aptos/aptos'; import { ETHEREUM } from '~/constants/chain/ethereum/ethereum'; @@ -16,6 +19,7 @@ import { SUI } from '~/constants/chain/sui/sui'; import { PRIVATE_KEY_FOR_TEST } from '~/constants/common'; import { APTOS_RPC_ERROR_MESSAGE, + BITCOIN_RPC_ERROR_MESSAGE, COSMOS_RPC_ERROR_MESSAGE, ETHEREUM_RPC_ERROR_MESSAGE, RPC_ERROR, @@ -25,12 +29,13 @@ import { import type { TOKEN_TYPE } from '~/constants/ethereum'; import { LEDGER_SUPPORT_COIN_TYPE } from '~/constants/ledger'; import { APTOS_METHOD_TYPE, APTOS_NO_POPUP_METHOD_TYPE, APTOS_POPUP_METHOD_TYPE } from '~/constants/message/aptos'; +import { BITCOIN_METHOD_TYPE, BITCOIN_NO_POPUP_METHOD_TYPE, BITCOIN_POPUP_METHOD_TYPE } from '~/constants/message/bitcoin'; import { COMMON_METHOD_TYPE, COMMON_NO_POPUP_METHOD_TYPE } from '~/constants/message/common'; import { COSMOS_METHOD_TYPE, COSMOS_NO_POPUP_METHOD_TYPE, COSMOS_POPUP_METHOD_TYPE } from '~/constants/message/cosmos'; import { ETHEREUM_METHOD_TYPE, ETHEREUM_NO_POPUP_METHOD_TYPE, ETHEREUM_POPUP_METHOD_TYPE } from '~/constants/message/ethereum'; import { SUI_METHOD_TYPE, SUI_NO_POPUP_METHOD_TYPE, SUI_POPUP_METHOD_TYPE } from '~/constants/message/sui'; import { getAddress, getKeyPair } from '~/Popup/utils/common'; -import { AptosRPCError, CommonRPCError, CosmosRPCError, EthereumRPCError, SuiRPCError } from '~/Popup/utils/error'; +import { AptosRPCError, BitcoinRPCError, CommonRPCError, CosmosRPCError, EthereumRPCError, SuiRPCError } from '~/Popup/utils/error'; import { requestRPC as ethereumRequestRPC, signTypedData } from '~/Popup/utils/ethereum'; import { extensionSessionStorage } from '~/Popup/utils/extensionSessionStorage'; import { extensionStorage, getStorage, setStorage } from '~/Popup/utils/extensionStorage'; @@ -38,6 +43,8 @@ import { openWindow } from '~/Popup/utils/extensionWindows'; import { responseToWeb } from '~/Popup/utils/message'; import { isEqualsIgnoringCase, toHex } from '~/Popup/utils/string'; import { requestRPC as suiRequestRPC } from '~/Popup/utils/sui'; +import type { AccountDetail } from '~/types/bitcoin/balance'; +import type { SendRawTransaction } from '~/types/bitcoin/transaction'; import type { CosmosChain, CosmosToken } from '~/types/chain'; import type { SendTransactionPayload } from '~/types/cosmos/common'; import type { CW20BalanceResponse, CW20TokenInfoResponse } from '~/types/cosmos/contract'; @@ -45,6 +52,7 @@ import type { ResponseRPC } from '~/types/ethereum/rpc'; import type { Queue } from '~/types/extensionStorage'; import type { ContentScriptToBackgroundEventMessage, RequestMessage } from '~/types/message'; import type { AptosConnectResponse, AptosIsConnectedResponse, AptosNetworkResponse, AptosSignMessage, AptosSignTransaction } from '~/types/message/aptos'; +import type { BitGetAddressResponse, BitGetBalanceResponse, BitRequestAccountResponse } from '~/types/message/bitcoin'; import type { ComProvidersResponse } from '~/types/message/common'; import type { CosAccountResponse, @@ -1924,4 +1932,331 @@ export async function cstob(request: ContentScriptToBackgroundEventMessage(`${chain.mempoolURL}/address/${address}`); + + const availableBalance = response.chain_stats.funded_txo_sum - response.chain_stats.spent_txo_sum - response.mempool_stats.spent_txo_sum; + + const result: BitGetBalanceResponse = availableBalance; + + responseToWeb({ + response: { + result, + }, + message, + messageId, + origin, + }); + } else { + const result = ''; + responseToWeb({ + response: { + result, + }, + message, + messageId, + origin, + }); + } + } + if (method === 'bit_getPublicKeyHex') { + if (currentAccountAllowedOrigins.includes(origin) && currentPassword && currentAccount.type !== 'LEDGER') { + const keyPair = getKeyPair(currentAccount, chain, currentPassword); + + const result: string = keyPair?.publicKey.toString('hex') || ''; + + responseToWeb({ + response: { + result, + }, + message, + messageId, + origin, + }); + } else { + const result = ''; + responseToWeb({ + response: { + result, + }, + message, + messageId, + origin, + }); + } + } + if (method === 'bit_getNetwork') { + const result = chain.isSignet ? Network.SIGNET : Network.MAINNET; + + responseToWeb({ + response: { + result, + }, + message, + messageId, + origin, + }); + } + if (method === 'bit_pushTx') { + if (currentAccountAllowedOrigins.includes(origin) && currentPassword && currentAccount.type !== 'LEDGER') { + try { + const { params } = message; + const tx = params[0]; + + const decodedTransaction = Transaction.fromHex(tx); + + if (!decodedTransaction) { + throw new BitcoinRPCError(RPC_ERROR.INVALID_PARAMS, 'Invalid transaction', message.id); + } + const response = await post(chain.rpcURL, { + jsonrpc: '2.0', + id: '1', + method: 'sendrawtransaction', + params: [tx], + }); + + const { result } = response; + + if (response.error || !result) { + throw new BitcoinRPCError(response.error?.code || RPC_ERROR.INTERNAL, response.error?.message || 'Fail to post tx', message.id); + } + + responseToWeb({ + response: { + result, + }, + message, + messageId, + origin, + }); + } catch (e) { + throw new BitcoinRPCError(RPC_ERROR.INTERNAL, 'error', message.id); + } + } else { + throw new BitcoinRPCError(RPC_ERROR.UNAUTHORIZED, BITCOIN_RPC_ERROR_MESSAGE[RPC_ERROR.UNAUTHORIZED], message.id); + } + } + } else { + throw new BitcoinRPCError(RPC_ERROR.INVALID_REQUEST, RPC_ERROR_MESSAGE[RPC_ERROR.INVALID_REQUEST], message.id); + } + } catch (e) { + if (e instanceof BitcoinRPCError) { + responseToWeb({ + response: e.rpcMessage, + message, + messageId, + origin, + }); + return; + } + + responseToWeb({ + response: { + error: { + code: RPC_ERROR.INTERNAL, + message: `${RPC_ERROR_MESSAGE[RPC_ERROR.INTERNAL]}`, + }, + }, + message, + messageId, + origin, + }); + } + } } diff --git a/src/Popup/components/ChainPopover/index.tsx b/src/Popup/components/ChainPopover/index.tsx index 3628b7f82..7132c9461 100644 --- a/src/Popup/components/ChainPopover/index.tsx +++ b/src/Popup/components/ChainPopover/index.tsx @@ -6,6 +6,7 @@ import { APTOS_CHAINS, BITCOIN_CHAINS, COSMOS_CHAINS, ETHEREUM_CHAINS, SUI_CHAIN import Divider from '~/Popup/components/common/Divider'; import Popover from '~/Popup/components/common/Popover'; import { useCurrentAptosNetwork } from '~/Popup/hooks/useCurrent/useCurrentAptosNetwork'; +import { useCurrentBitcoinNetwork } from '~/Popup/hooks/useCurrent/useCurrentBitcoinNetwork'; import { useCurrentChain } from '~/Popup/hooks/useCurrent/useCurrentChain'; import { useCurrentEthereumNetwork } from '~/Popup/hooks/useCurrent/useCurrentEthereumNetwork'; import { useCurrentShownAptosNetworks } from '~/Popup/hooks/useCurrent/useCurrentShownAptosNetworks'; @@ -43,6 +44,7 @@ export default function ChainPopover({ onClose, currentChain, onClickChain, isOn const { setCurrentChain } = useCurrentChain(); const { extensionStorage, setExtensionStorage } = useExtensionStorage(); const { currentEthereumNetwork, setCurrentEthereumNetwork, removeEthereumNetwork } = useCurrentEthereumNetwork(); + const { currentBitcoinNetwork, setCurrentBitcoinNetwork } = useCurrentBitcoinNetwork(); const { currentShownEthereumNetwork } = useCurrentShownEthereumNetworks(); const { currentAptosNetwork, setCurrentAptosNetwork, removeAptosNetwork } = useCurrentAptosNetwork(); @@ -150,8 +152,10 @@ export default function ChainPopover({ onClose, currentChain, onClickChain, isOn { + onClick={async () => { + await setCurrentBitcoinNetwork(chain); onClickChain?.(chain); onClose?.({}, 'backdropClick'); }} diff --git a/src/Popup/components/Wrapper/Init/index.tsx b/src/Popup/components/Wrapper/Init/index.tsx index b12a4e5af..96e878aa6 100644 --- a/src/Popup/components/Wrapper/Init/index.tsx +++ b/src/Popup/components/Wrapper/Init/index.tsx @@ -5,6 +5,7 @@ import { useRecoilState, useSetRecoilState } from 'recoil'; import { APTOS_NETWORKS, CHAINS, COSMOS_CHAINS, ETHEREUM_NETWORKS, SUI_NETWORKS } from '~/constants/chain'; import { APTOS } from '~/constants/chain/aptos/aptos'; import { MAINNET as APTOS_NETWORK_MAINNET } from '~/constants/chain/aptos/network/mainnet'; +import { BITCOIN } from '~/constants/chain/bitcoin/bitcoin'; import { COSMOS } from '~/constants/chain/cosmos/cosmos'; import { ETHEREUM } from '~/constants/chain/ethereum/ethereum'; import { MAINNET as SUI_NETWORK_MAINNET } from '~/constants/chain/sui/network/mainnet'; @@ -200,6 +201,10 @@ export default function Init({ children }: InitType) { await setStorage('selectedSuiNetworkId', SUI_NETWORK_MAINNET.id); } + if (!originExtensionStorage.selectedBitcoinChainId) { + await setStorage('selectedBitcoinChainId', BITCOIN.id); + } + if (!originExtensionStorage.address) { await setStorage('address', {}); } diff --git a/src/Popup/components/Wrapper/Routes/index.tsx b/src/Popup/components/Wrapper/Routes/index.tsx index ec390a547..6a2d3dc89 100644 --- a/src/Popup/components/Wrapper/Routes/index.tsx +++ b/src/Popup/components/Wrapper/Routes/index.tsx @@ -35,7 +35,8 @@ export default function Routes({ children }: RoutesType) { extensionStorage.queues[0].message.method === 'aptos_account' || extensionStorage.queues[0].message.method === 'aptos_connect' || extensionStorage.queues[0].message.method === 'sui_connect' || - extensionStorage.queues[0].message.method === 'sui_getAccount' + extensionStorage.queues[0].message.method === 'sui_getAccount' || + extensionStorage.queues[0].message.method === 'bit_requestAccount' ) { navigate('/popup/request-account'); } @@ -126,6 +127,16 @@ export default function Routes({ children }: RoutesType) { if (extensionStorage.queues[0].message.method === 'sui_signMessage' || extensionStorage.queues[0].message.method === 'sui_signPersonalMessage') { navigate('/popup/sui/sign-message'); } + + if (extensionStorage.queues[0].message.method === 'bitc_switchNetwork') { + navigate('/popup/bitcoin/switch-network'); + } + if (extensionStorage.queues[0].message.method === 'bit_sendBitcoin') { + navigate('/popup/bitcoin/send'); + } + if (extensionStorage.queues[0].message.method === 'bit_signPsbt') { + navigate('/popup/bitcoin/sign-psbt'); + } } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/Popup/hooks/useCurrent/useCurrentAccount.ts b/src/Popup/hooks/useCurrent/useCurrentAccount.ts index 4f9fd56a6..ae9933716 100644 --- a/src/Popup/hooks/useCurrent/useCurrentAccount.ts +++ b/src/Popup/hooks/useCurrent/useCurrentAccount.ts @@ -90,6 +90,8 @@ export function useCurrentAccount() { { line: 'SUI', type: 'accountChange', message: { result: '' } }, currentAccountNotOrigins.filter((item) => !currentAccountOrigins.includes(item)), ); + + emitToWeb({ line: 'BITCOIN', type: 'accountChanged' }, origins); }; const addAccount = async (accountInfo: AccountWithName) => { diff --git a/src/Popup/hooks/useCurrent/useCurrentBitcoinNetwork.ts b/src/Popup/hooks/useCurrent/useCurrentBitcoinNetwork.ts new file mode 100644 index 000000000..f300d02ad --- /dev/null +++ b/src/Popup/hooks/useCurrent/useCurrentBitcoinNetwork.ts @@ -0,0 +1,27 @@ +import { BITCOIN_CHAINS } from '~/constants/chain'; +import { useExtensionStorage } from '~/Popup/hooks/useExtensionStorage'; +import type { BitcoinChain } from '~/types/chain'; + +export function useCurrentBitcoinNetwork() { + const { extensionStorage, setExtensionStorage } = useExtensionStorage(); + + const { selectedBitcoinChainId } = extensionStorage; + + const allNetworks = [...BITCOIN_CHAINS]; + + const currentAccountSelectedBitcoinNetworkId = allNetworks.find((network) => network.id === selectedBitcoinChainId)?.id ?? allNetworks[0].id; + + const currentBitcoinNetwork = allNetworks.find((network) => network.id === currentAccountSelectedBitcoinNetworkId)!; + + const setCurrentBitcoinNetwork = async (chain: BitcoinChain) => { + const newSelectedEthereumNetworkId = chain.id; + + await setExtensionStorage('selectedBitcoinChainId', newSelectedEthereumNetworkId); + }; + + return { + bitcoinNetworks: allNetworks, + currentBitcoinNetwork, + setCurrentBitcoinNetwork, + }; +} diff --git a/src/Popup/i18n/en/translation.json b/src/Popup/i18n/en/translation.json index 00b2b23c1..df302db91 100644 --- a/src/Popup/i18n/en/translation.json +++ b/src/Popup/i18n/en/translation.json @@ -694,6 +694,45 @@ } } }, + "Bitcoin": { + "Send": { + "entry": { + "cancel": "Cancel", + "expectedFee": "Expected fee", + "failedCreateTxHex": "Failed to create transaction hex", + "failedLoadFee": "Failed to load fee", + "input": "Input", + "invalidAddress": "Invalid address", + "noAmount": "Invalid send amount", + "noAvailableAmount": "No available amount", + "output": "Output", + "sign": "Sign" + } + }, + "SignPsbt": { + "entry": { + "cancel": "Cancel", + "expectedFee": "Expected fee", + "failedCreateTxHex": "Failed to create transaction hex", + "failedLoadFee": "Failed to load fee", + "input": "Input", + "invalidAddress": "Invalid address", + "invalidSender": "Invalid sender", + "noAmount": "Invalid send amount", + "noAvailableAmount": "No available amount", + "output": "Output", + "sign": "Sign" + } + }, + "SwitchNetwork": { + "entry": { + "cancelButton": "Cancel", + "description": "Switching to a different network will disconnect your current network. Are you sure you want to switch networks?", + "question": "Switch network", + "switchButton": "Switch" + } + } + }, "Cosmos": { "AddChain": { "entry": { diff --git a/src/Popup/i18n/ko/translation.json b/src/Popup/i18n/ko/translation.json index 0ac97c700..47bbef229 100644 --- a/src/Popup/i18n/ko/translation.json +++ b/src/Popup/i18n/ko/translation.json @@ -694,6 +694,45 @@ } } }, + "Bitcoin": { + "Send": { + "entry": { + "cancel": "취소", + "expectedFee": "예상 수수료", + "failedCreateTxHex": "트랜잭션 생성에 실패했습니다.", + "failedLoadFee": "수수료 로딩에 실패했습니다.", + "input": "Input", + "invalidAddress": "유효하지 않은 주소입니다.", + "noAmount": "보낼 수량이 없습니다.", + "noAvailableAmount": "잔액이 부족합니다.", + "output": "Output", + "sign": "서명" + } + }, + "SignPsbt": { + "entry": { + "cancel": "취소", + "expectedFee": "예상 수수료", + "failedCreateTxHex": "트랜잭션 생성에 실패했습니다.", + "failedLoadFee": "수수료 로딩에 실패했습니다.", + "input": "Input", + "invalidAddress": "유효하지 않은 주소입니다.", + "invalidSender": "보내는 주소가 잘못되었습니다.", + "noAmount": "보낼 수량이 없습니다.", + "noAvailableAmount": "잔액이 부족합니다.", + "output": "Output", + "sign": "서명" + } + }, + "SwitchNetwork": { + "entry": { + "cancelButton": "취소", + "description": "비트코인 네트워크를 전환하시겠습니까?", + "question": "네트워크 전환", + "switchButton": "전환" + } + } + }, "Cosmos": { "AddChain": { "entry": { diff --git a/src/Popup/pages/Account/Initialize/entry.tsx b/src/Popup/pages/Account/Initialize/entry.tsx index 61996b4fc..2a1f287f5 100644 --- a/src/Popup/pages/Account/Initialize/entry.tsx +++ b/src/Popup/pages/Account/Initialize/entry.tsx @@ -53,6 +53,7 @@ export default function Entry() { void setExtensionStorage('selectedEthereumNetworkId', ETHEREUM_NETWORKS[0].id); void setExtensionStorage('selectedAptosNetworkId', APTOS_NETWORKS[0].id); void setExtensionStorage('selectedSuiNetworkId', SUI_NETWORKS[0].id); + void setExtensionStorage('selectedBitcoinChainId', BITCOIN.id); void setExtensionStorage('encryptedPassword', null); void setCurrentPassword(null); diff --git a/src/Popup/pages/Popup/Bitcoin/Send/components/TxHex/index.tsx b/src/Popup/pages/Popup/Bitcoin/Send/components/TxHex/index.tsx new file mode 100644 index 000000000..990338e91 --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/Send/components/TxHex/index.tsx @@ -0,0 +1,15 @@ +import { Typography } from '@mui/material'; + +import { Container } from './styled'; + +type TxHexProps = { + txHex: string; +}; + +export default function Tx({ txHex }: TxHexProps) { + return ( + + {txHex} + + ); +} diff --git a/src/Popup/pages/Popup/Bitcoin/Send/components/TxHex/styled.tsx b/src/Popup/pages/Popup/Bitcoin/Send/components/TxHex/styled.tsx new file mode 100644 index 000000000..420ba18f2 --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/Send/components/TxHex/styled.tsx @@ -0,0 +1,16 @@ +import { styled } from '@mui/material/styles'; + +export const Container = styled('div')(({ theme }) => ({ + padding: '1.6rem', + + backgroundColor: theme.colors.base02, + + color: theme.colors.text01, + borderRadius: '0.8rem', + + height: '100%', + overflow: 'auto', + + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', +})); diff --git a/src/Popup/pages/Popup/Bitcoin/Send/components/TxMessageContainer/index.tsx b/src/Popup/pages/Popup/Bitcoin/Send/components/TxMessageContainer/index.tsx new file mode 100644 index 000000000..9617234fc --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/Send/components/TxMessageContainer/index.tsx @@ -0,0 +1,21 @@ +import { Typography } from '@mui/material'; + +import { StyledContainer, StyledDivider, TitleContainer } from './styled'; + +type TxMessageContainerProps = { + title: string; + children?: JSX.Element; + isMultipleMsgs?: boolean; +}; + +export default function TxMessageContainer({ title, children, isMultipleMsgs }: TxMessageContainerProps) { + return ( + + + {title} + + + {children} + + ); +} diff --git a/src/Popup/pages/Popup/Bitcoin/Send/components/TxMessageContainer/styled.tsx b/src/Popup/pages/Popup/Bitcoin/Send/components/TxMessageContainer/styled.tsx new file mode 100644 index 000000000..ec0144a82 --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/Send/components/TxMessageContainer/styled.tsx @@ -0,0 +1,26 @@ +import { styled } from '@mui/material/styles'; + +import Divider from '~/Popup/components/common/Divider'; + +type StyledContainerProps = { + 'data-is-multiple'?: boolean; +}; +export const StyledContainer = styled('div')(({ theme, ...props }) => ({ + padding: '1.6rem', + + backgroundColor: theme.colors.base02, + borderRadius: '0.8rem', + + height: props['data-is-multiple'] ? '15.7rem' : '18.7rem', + + display: 'flex', + flexDirection: 'column', +})); + +export const TitleContainer = styled('div')(({ theme }) => ({ + color: theme.colors.text01, +})); + +export const StyledDivider = styled(Divider)({ + marginTop: '1.6rem', +}); diff --git a/src/Popup/pages/Popup/Bitcoin/Send/entry.tsx b/src/Popup/pages/Popup/Bitcoin/Send/entry.tsx new file mode 100644 index 000000000..5422ac1f5 --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/Send/entry.tsx @@ -0,0 +1,412 @@ +import { useCallback, useMemo, useState } from 'react'; +import validate from 'bitcoin-address-validation'; +import { address as addressConverter, networks, payments, Psbt } from 'bitcoinjs-lib'; +import { useSnackbar } from 'notistack'; +import { Typography } from '@mui/material'; + +import { P2WPKH__V_BYTES } from '~/constants/bitcoin'; +import { RPC_ERROR, RPC_ERROR_MESSAGE } from '~/constants/error'; +import Button from '~/Popup/components/common/Button'; +import Number from '~/Popup/components/common/Number'; +import OutlineButton from '~/Popup/components/common/OutlineButton'; +import { Tab, Tabs } from '~/Popup/components/common/Tab'; +import Tooltip from '~/Popup/components/common/Tooltip'; +import LedgerToTab from '~/Popup/components/Loading/LedgerToTab'; +import { useBalanceSWR } from '~/Popup/hooks/SWR/bitcoin/useBalanceSWR'; +import { useEstimatesmartfeeSWR } from '~/Popup/hooks/SWR/bitcoin/useEstimatesmartfeeSWR'; +import { useUtxoSWR } from '~/Popup/hooks/SWR/bitcoin/useUtxoSWR'; +import { useCoinGeckoPriceSWR } from '~/Popup/hooks/SWR/useCoinGeckoPriceSWR'; +import { useCurrentAccount } from '~/Popup/hooks/useCurrent/useCurrentAccount'; +import { useCurrentBitcoinNetwork } from '~/Popup/hooks/useCurrent/useCurrentBitcoinNetwork'; +import { useCurrentPassword } from '~/Popup/hooks/useCurrent/useCurrentPassword'; +import { useCurrentQueue } from '~/Popup/hooks/useCurrent/useCurrentQueue'; +import { useExtensionStorage } from '~/Popup/hooks/useExtensionStorage'; +import { useLoading } from '~/Popup/hooks/useLoading'; +import { useTranslation } from '~/Popup/hooks/useTranslation'; +import { post } from '~/Popup/utils/axios'; +import { times, toDisplayDenomAmount } from '~/Popup/utils/big'; +import { getKeyPair } from '~/Popup/utils/common'; +import { ecpairFromPrivateKey } from '~/Popup/utils/crypto'; +import { responseToWeb } from '~/Popup/utils/message'; +import { shorterAddress } from '~/Popup/utils/string'; +import type { SendRawTransaction } from '~/types/bitcoin/transaction'; +import type { Queue } from '~/types/extensionStorage'; +import type { BitSendBitcoin } from '~/types/message/bitcoin'; + +import Tx from './components/TxHex'; +import TxMessageContainer from './components/TxMessageContainer'; +import { + AddressContainer, + AmountContainer, + BottomButtonContainer, + BottomContainer, + Container, + ContentContainer, + DenomContainer, + FeeContainer, + FeeInfoContainer, + FeeLeftContainer, + FeeRightAmountContainer, + FeeRightColumnContainer, + FeeRightContainer, + FeeRightValueContainer, + InOutputContainer, + LabelContainer, + SectionContainer, + StyledTabPanel, + TxMessageContentContainer, + WarningContainer, + WarningIconContainer, + WarningTextContainer, +} from './styled'; +import Header from '../components/Header'; + +import Info16Icon from '~/images/icons/Info16.svg'; + +type EntryProps = { + queue: Queue; +}; + +export default function Entry({ queue }: EntryProps) { + const { message, messageId, origin } = queue; + const { params } = message; + + const { extensionStorage } = useExtensionStorage(); + const coinGeckoPrice = useCoinGeckoPriceSWR(); + const { enqueueSnackbar } = useSnackbar(); + + const { currency } = extensionStorage; + const { setLoadingLedgerSigning } = useLoading(); + + const { currentBitcoinNetwork } = useCurrentBitcoinNetwork(); + + const balance = useBalanceSWR(currentBitcoinNetwork); + const utxo = useUtxoSWR(currentBitcoinNetwork); + + const { coinGeckoId } = currentBitcoinNetwork; + + const price = useMemo(() => (coinGeckoId && coinGeckoPrice.data?.[coinGeckoId]?.[currency]) || 0, [coinGeckoId, coinGeckoPrice.data, currency]); + + const { deQueue } = useCurrentQueue(); + + const { currentAccount } = useCurrentAccount(); + const { currentPassword } = useCurrentPassword(); + const [isProgress, setIsProgress] = useState(false); + + const { t } = useTranslation(); + + const [tabValue, setTabValue] = useState(0); + + const decimals = useMemo(() => currentBitcoinNetwork.decimals || 0, [currentBitcoinNetwork.decimals]); + + const symbol = useMemo(() => currentBitcoinNetwork.displayDenom || 'BTC', [currentBitcoinNetwork.displayDenom]); + + const network = useMemo(() => (currentBitcoinNetwork.isSignet ? networks.testnet : networks.bitcoin), [currentBitcoinNetwork.isSignet]); + + const { to, satAmount } = params; + + const estimatesmartfee = useEstimatesmartfeeSWR(currentBitcoinNetwork); + + const gasRate = useMemo(() => { + if (!estimatesmartfee.data?.result?.feerate) { + return null; + } + + return estimatesmartfee.data?.result?.feerate; + }, [estimatesmartfee.data?.result?.feerate]); + + const keyPair = useMemo(() => getKeyPair(currentAccount, currentBitcoinNetwork, currentPassword), [currentAccount, currentBitcoinNetwork, currentPassword]); + + const p2wpkh = useMemo(() => payments.p2wpkh({ pubkey: keyPair!.publicKey, network }), [keyPair, network]); + + const availableAmount = useMemo(() => { + if (!balance.data) { + return 0; + } + + return balance.data.chain_stats.funded_txo_sum - balance.data.chain_stats.spent_txo_sum - balance.data.mempool_stats.spent_txo_sum; + }, [balance.data]); + + const currentMemoBytes = useMemo(() => Buffer.from('', 'utf8').length, []); + + const currentVbytes = useMemo(() => { + if (!utxo.data?.length) { + return 0; + } + + const isMemo = currentMemoBytes > 0; + + return (utxo.data.length || 0) * P2WPKH__V_BYTES.INPUT + 2 * P2WPKH__V_BYTES.OUTPUT + P2WPKH__V_BYTES.OVERHEAD + (isMemo ? 3 : 0) + currentMemoBytes; + }, [currentMemoBytes, utxo.data]); + + const fee = useMemo(() => { + if (!gasRate) { + return 0; + } + + return Math.ceil(currentVbytes * gasRate * 100000); + }, [gasRate, currentVbytes]); + + const displayFee = useMemo(() => toDisplayDenomAmount(fee, decimals), [fee, decimals]); + + const change = useMemo(() => availableAmount - satAmount - fee, [availableAmount, satAmount, fee]); + + const displayFeePrice = useMemo(() => times(displayFee, price), [displayFee, price]); + + const currentInputs = useMemo( + () => + utxo.data?.map((u) => ({ + hash: u.txid, + index: u.vout, + witnessUtxo: { + script: p2wpkh.output!, + value: u.value, + }, + })), + [p2wpkh.output, utxo.data], + ); + + const currentOutputs = useMemo( + () => [ + { + address: to, + value: satAmount, + }, + { + address: p2wpkh.address!, + value: change, + }, + ], + [change, p2wpkh.address, satAmount, to], + ); + + const txHex = useMemo(() => { + try { + const psbt = new Psbt({ network }); + + if (currentInputs) { + psbt.addInputs(currentInputs); + } + + psbt.addOutputs(currentOutputs); + + if (currentMemoBytes > 0) { + const memo = Buffer.from('', 'utf8'); + psbt.addOutput({ script: payments.embed({ data: [memo] }).output!, value: 0 }); + } + + return psbt.signAllInputs(ecpairFromPrivateKey(keyPair!.privateKey!)).finalizeAllInputs().extractTransaction().toHex(); + } catch { + return null; + } + }, [currentInputs, currentMemoBytes, currentOutputs, keyPair, network]); + + const errorMessage = useMemo(() => { + if (!validate(to)) { + return t('pages.Popup.Bitcoin.Send.entry.invalidAddress'); + } + + if (gasRate === null) { + return t('pages.Popup.Bitcoin.Send.entry.failedLoadFee'); + } + + if (availableAmount === 0 || availableAmount - satAmount - fee < 0) { + return t('pages.Popup.Bitcoin.Send.entry.noAvailableAmount'); + } + + if (!satAmount) { + return t('pages.Popup.Bitcoin.Send.entry.noAmount'); + } + + if (!txHex) { + return t('pages.Popup.Bitcoin.Send.entry.failedCreateTxHex'); + } + + return ''; + }, [availableAmount, fee, gasRate, satAmount, t, to, txHex]); + + const handleChange = useCallback((_: React.SyntheticEvent, newTabValue: number) => { + setTabValue(newTabValue); + }, []); + + return ( + + + + + + + + + + + + + {t('pages.Popup.Bitcoin.Send.entry.input')} + + + {currentInputs?.map((item) => { + const { value, script } = item.witnessUtxo; + const addressFromOutputScript = addressConverter.fromOutputScript(script, network); + + const displayValue = toDisplayDenomAmount(value || '0', decimals); + + return ( + + + {shorterAddress(addressFromOutputScript, 14)} + + + + {displayValue} + + + + {currentBitcoinNetwork.displayDenom} + + + + ); + })} + + + + + {t('pages.Popup.Bitcoin.Send.entry.output')} + + {currentOutputs.map((item) => { + const displayValue = toDisplayDenomAmount(item.value, decimals); + + return ( + + + {shorterAddress(item.address, 14)} + + + + {displayValue} + + + + {currentBitcoinNetwork.displayDenom} + + + + ); + })} + + + + + + + {t('pages.Popup.Bitcoin.Send.entry.expectedFee')} + + + + + + {displayFee} + + + {symbol} + + + + {displayFeePrice} + + + + + + + + + + + + + {errorMessage && ( + + + + + + {errorMessage} + + + )} + + { + responseToWeb({ + response: { + error: { + code: RPC_ERROR.USER_REJECTED_REQUEST, + message: `${RPC_ERROR_MESSAGE[RPC_ERROR.USER_REJECTED_REQUEST]}`, + }, + }, + message, + messageId, + origin, + }); + + await deQueue(); + }} + > + {t('pages.Popup.Bitcoin.Send.entry.cancel')} + + + + { + try { + setIsProgress(true); + + const response = await post( + currentBitcoinNetwork.rpcURL, + { + jsonrpc: '2.0', + id: '1', + method: 'sendrawtransaction', + params: [txHex], + }, + { headers: { 'Content-Type': 'application/json' } }, + ); + + if (!response.result) { + throw new Error('Failed to sign transaction'); + } + + const { result } = response; + + responseToWeb({ + response: { + result, + }, + message, + messageId, + origin, + }); + + await deQueue(); + } catch (e) { + enqueueSnackbar((e as { message: string }).message, { variant: 'error' }); + } finally { + setLoadingLedgerSigning(false); + setIsProgress(false); + } + }} + > + {t('pages.Popup.Bitcoin.Send.entry.sign')} + + + + + + + + ); +} diff --git a/src/Popup/pages/Popup/Bitcoin/Send/index.tsx b/src/Popup/pages/Popup/Bitcoin/Send/index.tsx new file mode 100644 index 000000000..e29f424ff --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/Send/index.tsx @@ -0,0 +1,41 @@ +import { Suspense } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; + +import Empty from '~/Popup/components/common/Empty'; +import Lock from '~/Popup/components/Lock'; +import AccessRequest from '~/Popup/components/requests/AccessRequest'; +import LedgerPublicKeyRequest from '~/Popup/components/requests/LedgerPublicKeyRequest'; +import { useCurrentQueue } from '~/Popup/hooks/useCurrent/useCurrentQueue'; +import type { Queue } from '~/types/extensionStorage'; +import type { BitSendBitcoin } from '~/types/message/bitcoin'; + +import Entry from './entry'; +import Layout from './layout'; + +export default function Send() { + const { currentQueue } = useCurrentQueue(); + + if (currentQueue && isBitcoinTransaction(currentQueue)) { + return ( + + + + + }> + + + + + + + + + ); + } + + return null; +} + +function isBitcoinTransaction(queue: Queue): queue is Queue { + return queue?.message?.method === 'bit_sendBitcoin'; +} diff --git a/src/Popup/pages/Popup/Bitcoin/Send/layout.tsx b/src/Popup/pages/Popup/Bitcoin/Send/layout.tsx new file mode 100644 index 000000000..27b2215f8 --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/Send/layout.tsx @@ -0,0 +1,9 @@ +import BaseLayout from '~/Popup/components/BaseLayout'; + +type LayoutProps = { + children: JSX.Element; +}; + +export default function Layout({ children }: LayoutProps) { + return {children}; +} diff --git a/src/Popup/pages/Popup/Bitcoin/Send/styled.tsx b/src/Popup/pages/Popup/Bitcoin/Send/styled.tsx new file mode 100644 index 000000000..d4c632562 --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/Send/styled.tsx @@ -0,0 +1,211 @@ +import { CircularProgress } from '@mui/material'; +import { styled } from '@mui/material/styles'; + +import { TabPanel } from '~/Popup/components/common/Tab'; + +export const Container = styled('div')({ + position: 'relative', + + height: '100%', +}); + +export const ContentContainer = styled('div')({ + height: 'calc(100% - 17.2rem)', + + padding: '1.2rem 1.6rem 0', +}); + +export const StyledTabPanel = styled(TabPanel)({ + marginTop: '1.6rem', + height: 'calc(100% - 4.8rem)', +}); + +export const FeeContainer = styled('div')(({ theme }) => ({ + marginTop: '0.8rem', + padding: '1.6rem', + border: `0.1rem solid ${theme.colors.base03}`, + + borderRadius: '0.8rem', +})); + +export const FeeInfoContainer = styled('div')({ + display: 'flex', + justifyContent: 'space-between', +}); + +export const FeeLeftContainer = styled('div')(({ theme }) => ({ + color: theme.colors.text02, + + display: 'flex', +})); + +export const FeeRightContainer = styled('div')({ + display: 'flex', + justifyContent: 'flex-end', +}); + +export const FeeRightColumnContainer = styled('div')({}); + +export const FeeRightAmountContainer = styled('div')(({ theme }) => ({ + display: 'flex', + justifyContent: 'flex-end', + + color: theme.colors.text01, +})); + +export const FeeRightValueContainer = styled('div')(({ theme }) => ({ + marginTop: '0.2rem', + + display: 'flex', + justifyContent: 'flex-end', + + color: theme.colors.text02, +})); + +export const FeeEditContainer = styled('div')({ + display: 'flex', + justifyContent: 'space-between', + + marginTop: '1.2rem', +}); + +export const FeeEditLeftContainer = styled('div')({ display: 'flex', justifyContent: 'flex-start' }); + +export const FeeEditRightContainer = styled('div')({ + display: 'flex', + justifyContent: 'flex-end', + + '& > :nth-of-type(n + 1)': { marginLeft: '0.4rem' }, +}); + +type FeeButtonProps = { + 'data-is-active'?: number; +}; + +export const FeeButton = styled('button')(({ theme, ...props }) => ({ + border: 0, + padding: '0.4rem 0', + + borderRadius: '5rem', + + minWidth: '5rem', + + backgroundColor: props['data-is-active'] ? theme.accentColors.purple01 : theme.colors.base03, + color: props['data-is-active'] ? theme.colors.text01 : theme.colors.text02, + + '& > svg': { + fill: props['data-is-active'] ? theme.colors.text01 : theme.colors.text02, + '& > path': { + fill: props['data-is-active'] ? theme.colors.text01 : theme.colors.text02, + }, + }, + + cursor: 'pointer', +})); + +export const FeeEditButton = styled(FeeButton)({ + minWidth: '2rem', + + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + + borderRadius: '10rem', + + padding: '0 0.4rem', +}); + +export const BottomContainer = styled('div')({ + position: 'absolute', + + bottom: '1.6rem', + left: '1.6rem', + + width: 'calc(100% - 3.2rem)', +}); + +export const BottomButtonContainer = styled('div')({ + display: 'grid', + gridTemplateColumns: '1fr 1fr', + columnGap: '0.8rem', +}); + +export const StyledCircularProgress = styled(CircularProgress)(({ theme }) => ({ + marginLeft: '0.4rem', + '&.MuiCircularProgress-root': { + color: theme.accentColors.green01, + }, +})); + +export const WarningContainer = styled('div')({ + padding: '1.2rem 2.2rem 1.2rem 1.6rem', + + wordBreak: 'break-all', + + borderRadius: '0.8rem', + + backgroundColor: 'rgba(205, 26, 26, 0.15)', + + display: 'flex', + + marginBottom: '0.8rem', +}); + +export const WarningIconContainer = styled('div')(({ theme }) => ({ + '& > svg': { + '& > path': { + fill: theme.accentColors.red, + }, + }, +})); + +export const WarningTextContainer = styled('div')(({ theme }) => ({ + marginLeft: '0.4rem', + color: theme.accentColors.red, +})); + +export const TxMessageContentContainer = styled('div')(({ theme }) => ({ + color: theme.colors.text01, + + margin: '0 -1.6rem', + padding: '1.2rem 1.6rem 0', + + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', + + overflow: 'auto', + + display: 'flex', + flexDirection: 'column', + rowGap: '0.6rem', +})); + +export const InOutputContainer = styled('div')({ + display: 'flex', + justifyContent: 'space-between', +}); + +export const AddressContainer = styled('div')(({ theme }) => ({ + color: theme.colors.text01, +})); + +export const AmountContainer = styled('div')({ + display: 'flex', + alignItems: 'center', + columnGap: '0.4rem', +}); + +export const SectionContainer = styled('div')({ + display: 'flex', + flexDirection: 'column', + + rowGap: '0.4rem', +}); + +export const DenomContainer = styled('div')(({ theme }) => ({ + color: theme.colors.text01, +})); + +export const LabelContainer = styled('div')(({ theme }) => ({ + color: theme.colors.text02, +})); diff --git a/src/Popup/pages/Popup/Bitcoin/SignPsbt/components/TxHex/index.tsx b/src/Popup/pages/Popup/Bitcoin/SignPsbt/components/TxHex/index.tsx new file mode 100644 index 000000000..990338e91 --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/SignPsbt/components/TxHex/index.tsx @@ -0,0 +1,15 @@ +import { Typography } from '@mui/material'; + +import { Container } from './styled'; + +type TxHexProps = { + txHex: string; +}; + +export default function Tx({ txHex }: TxHexProps) { + return ( + + {txHex} + + ); +} diff --git a/src/Popup/pages/Popup/Bitcoin/SignPsbt/components/TxHex/styled.tsx b/src/Popup/pages/Popup/Bitcoin/SignPsbt/components/TxHex/styled.tsx new file mode 100644 index 000000000..420ba18f2 --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/SignPsbt/components/TxHex/styled.tsx @@ -0,0 +1,16 @@ +import { styled } from '@mui/material/styles'; + +export const Container = styled('div')(({ theme }) => ({ + padding: '1.6rem', + + backgroundColor: theme.colors.base02, + + color: theme.colors.text01, + borderRadius: '0.8rem', + + height: '100%', + overflow: 'auto', + + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', +})); diff --git a/src/Popup/pages/Popup/Bitcoin/SignPsbt/components/TxMessageContainer/index.tsx b/src/Popup/pages/Popup/Bitcoin/SignPsbt/components/TxMessageContainer/index.tsx new file mode 100644 index 000000000..9617234fc --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/SignPsbt/components/TxMessageContainer/index.tsx @@ -0,0 +1,21 @@ +import { Typography } from '@mui/material'; + +import { StyledContainer, StyledDivider, TitleContainer } from './styled'; + +type TxMessageContainerProps = { + title: string; + children?: JSX.Element; + isMultipleMsgs?: boolean; +}; + +export default function TxMessageContainer({ title, children, isMultipleMsgs }: TxMessageContainerProps) { + return ( + + + {title} + + + {children} + + ); +} diff --git a/src/Popup/pages/Popup/Bitcoin/SignPsbt/components/TxMessageContainer/styled.tsx b/src/Popup/pages/Popup/Bitcoin/SignPsbt/components/TxMessageContainer/styled.tsx new file mode 100644 index 000000000..ec0144a82 --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/SignPsbt/components/TxMessageContainer/styled.tsx @@ -0,0 +1,26 @@ +import { styled } from '@mui/material/styles'; + +import Divider from '~/Popup/components/common/Divider'; + +type StyledContainerProps = { + 'data-is-multiple'?: boolean; +}; +export const StyledContainer = styled('div')(({ theme, ...props }) => ({ + padding: '1.6rem', + + backgroundColor: theme.colors.base02, + borderRadius: '0.8rem', + + height: props['data-is-multiple'] ? '15.7rem' : '18.7rem', + + display: 'flex', + flexDirection: 'column', +})); + +export const TitleContainer = styled('div')(({ theme }) => ({ + color: theme.colors.text01, +})); + +export const StyledDivider = styled(Divider)({ + marginTop: '1.6rem', +}); diff --git a/src/Popup/pages/Popup/Bitcoin/SignPsbt/entry.tsx b/src/Popup/pages/Popup/Bitcoin/SignPsbt/entry.tsx new file mode 100644 index 000000000..c826f892e --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/SignPsbt/entry.tsx @@ -0,0 +1,446 @@ +import { useCallback, useMemo, useState } from 'react'; +import validate from 'bitcoin-address-validation'; +import { address as addressConverter, networks, opcodes, Psbt, script } from 'bitcoinjs-lib'; +import { useSnackbar } from 'notistack'; +import { Typography } from '@mui/material'; + +import { P2WPKH__V_BYTES } from '~/constants/bitcoin'; +import { RPC_ERROR, RPC_ERROR_MESSAGE } from '~/constants/error'; +import Button from '~/Popup/components/common/Button'; +import Number from '~/Popup/components/common/Number'; +import OutlineButton from '~/Popup/components/common/OutlineButton'; +import { Tab, Tabs } from '~/Popup/components/common/Tab'; +import Tooltip from '~/Popup/components/common/Tooltip'; +import LedgerToTab from '~/Popup/components/Loading/LedgerToTab'; +import { useBalanceSWR } from '~/Popup/hooks/SWR/bitcoin/useBalanceSWR'; +import { useEstimatesmartfeeSWR } from '~/Popup/hooks/SWR/bitcoin/useEstimatesmartfeeSWR'; +import { useUtxoSWR } from '~/Popup/hooks/SWR/bitcoin/useUtxoSWR'; +import { useAccounts } from '~/Popup/hooks/SWR/cache/useAccounts'; +import { useCoinGeckoPriceSWR } from '~/Popup/hooks/SWR/useCoinGeckoPriceSWR'; +import { useCurrentAccount } from '~/Popup/hooks/useCurrent/useCurrentAccount'; +import { useCurrentBitcoinNetwork } from '~/Popup/hooks/useCurrent/useCurrentBitcoinNetwork'; +import { useCurrentPassword } from '~/Popup/hooks/useCurrent/useCurrentPassword'; +import { useCurrentQueue } from '~/Popup/hooks/useCurrent/useCurrentQueue'; +import { useExtensionStorage } from '~/Popup/hooks/useExtensionStorage'; +import { useLoading } from '~/Popup/hooks/useLoading'; +import { useTranslation } from '~/Popup/hooks/useTranslation'; +import { post } from '~/Popup/utils/axios'; +import { gte, plus, times, toDisplayDenomAmount } from '~/Popup/utils/big'; +import { formatPsbtHex } from '~/Popup/utils/bitcoin'; +import { getKeyPair } from '~/Popup/utils/common'; +import { ecpairFromPrivateKey, ecpairFromPublicKey } from '~/Popup/utils/crypto'; +import { responseToWeb } from '~/Popup/utils/message'; +import { isEqualsIgnoringCase, shorterAddress } from '~/Popup/utils/string'; +import type { SendRawTransaction } from '~/types/bitcoin/transaction'; +import type { Queue } from '~/types/extensionStorage'; +import type { BitSignPsbt } from '~/types/message/bitcoin'; + +import Tx from './components/TxHex'; +import TxMessageContainer from './components/TxMessageContainer'; +import { + AddressContainer, + AmountContainer, + BottomButtonContainer, + BottomContainer, + Container, + ContentContainer, + DenomContainer, + FeeContainer, + FeeInfoContainer, + FeeLeftContainer, + FeeRightAmountContainer, + FeeRightColumnContainer, + FeeRightContainer, + FeeRightValueContainer, + InOutputContainer, + LabelContainer, + SectionContainer, + StyledTabPanel, + TxMessageContentContainer, + WarningContainer, + WarningIconContainer, + WarningTextContainer, +} from './styled'; +import Header from '../components/Header'; + +import Info16Icon from '~/images/icons/Info16.svg'; + +type EntryProps = { + queue: Queue; +}; + +export default function Entry({ queue }: EntryProps) { + const { message, messageId, origin } = queue; + const { params } = message; + + const { extensionStorage } = useExtensionStorage(); + const coinGeckoPrice = useCoinGeckoPriceSWR(); + const { enqueueSnackbar } = useSnackbar(); + + const { currency } = extensionStorage; + const { setLoadingLedgerSigning } = useLoading(); + + const { currentBitcoinNetwork } = useCurrentBitcoinNetwork(); + + const balance = useBalanceSWR(currentBitcoinNetwork); + const utxo = useUtxoSWR(currentBitcoinNetwork); + + const { coinGeckoId } = currentBitcoinNetwork; + + const price = useMemo(() => (coinGeckoId && coinGeckoPrice.data?.[coinGeckoId]?.[currency]) || 0, [coinGeckoId, coinGeckoPrice.data, currency]); + + const { deQueue } = useCurrentQueue(); + + const { currentAccount } = useCurrentAccount(); + const { currentPassword } = useCurrentPassword(); + const [isProgress, setIsProgress] = useState(false); + + const accounts = useAccounts(true); + + const address = useMemo( + () => accounts.data?.find((item) => item.id === currentAccount.id)?.address[currentBitcoinNetwork.id] || '', + [accounts.data, currentAccount.id, currentBitcoinNetwork.id], + ); + const { t } = useTranslation(); + + const [tabValue, setTabValue] = useState(0); + + const decimals = useMemo(() => currentBitcoinNetwork.decimals || 0, [currentBitcoinNetwork.decimals]); + + const symbol = useMemo(() => currentBitcoinNetwork.displayDenom || 'BTC', [currentBitcoinNetwork.displayDenom]); + + const network = useMemo(() => (currentBitcoinNetwork.isSignet ? networks.testnet : networks.bitcoin), [currentBitcoinNetwork.isSignet]); + + const psbtHex = params; + + const parsedPsbt = useMemo(() => { + const formattedPsbtHex = formatPsbtHex(psbtHex); + const psbt = Psbt.fromHex(formattedPsbtHex); + + return psbt; + }, [psbtHex]); + + const estimatesmartfee = useEstimatesmartfeeSWR(currentBitcoinNetwork); + + const gasRate = useMemo(() => { + if (!estimatesmartfee.data?.result?.feerate) { + return null; + } + + return estimatesmartfee.data?.result?.feerate; + }, [estimatesmartfee.data?.result?.feerate]); + + const keyPair = useMemo(() => getKeyPair(currentAccount, currentBitcoinNetwork, currentPassword), [currentAccount, currentBitcoinNetwork, currentPassword]); + + const availableAmount = useMemo(() => { + if (!balance.data) { + return 0; + } + + return balance.data.chain_stats.funded_txo_sum - balance.data.chain_stats.spent_txo_sum - balance.data.mempool_stats.spent_txo_sum; + }, [balance.data]); + + const memoBytes = useMemo(() => { + if (!parsedPsbt) { + return 0; + } + + const opReturnOutput = parsedPsbt.txOutputs.find((output) => { + try { + const chunks = script.decompile(output.script); + return chunks && chunks[0] === opcodes.OP_RETURN; + } catch (e) { + return false; + } + }); + + if (opReturnOutput) { + return opReturnOutput.script.length; + } + + return 0; + }, [parsedPsbt]); + + const currentVbytes = useMemo(() => { + if (!utxo.data?.length) { + return 0; + } + + const isMemo = memoBytes > 0; + + return (utxo.data.length || 0) * P2WPKH__V_BYTES.INPUT + 2 * P2WPKH__V_BYTES.OUTPUT + P2WPKH__V_BYTES.OVERHEAD + (isMemo ? 3 : 0) + memoBytes; + }, [memoBytes, utxo.data?.length]); + + const fee = useMemo(() => { + if (!gasRate) { + return 0; + } + + return Math.ceil(currentVbytes * gasRate * 100000); + }, [gasRate, currentVbytes]); + + const displayFee = useMemo(() => toDisplayDenomAmount(fee, decimals), [fee, decimals]); + + const displayFeePrice = useMemo(() => times(displayFee, price), [displayFee, price]); + + const currentInputs = useMemo(() => { + const witnessUtxoMapedList = parsedPsbt.data.inputs.map((item) => { + if (!item.witnessUtxo) { + return undefined; + } + + const scriptBuffer = item.witnessUtxo.script; + const addressFromScript = addressConverter.fromOutputScript(scriptBuffer, network); + + return { + address: addressFromScript, + value: item.witnessUtxo?.value, + }; + }); + + return witnessUtxoMapedList.filter((item) => !!item) as { + address: string; + value: number; + }[]; + }, [network, parsedPsbt.data.inputs]); + + const totalInputAmount = useMemo(() => currentInputs.reduce((totalVal, cur) => plus(totalVal, cur?.value || '0'), '0'), [currentInputs]); + + const canSendTx = useMemo(() => gte(availableAmount, totalInputAmount), [availableAmount, totalInputAmount]); + + const currentOutputs = useMemo(() => { + const mappedOutputs = parsedPsbt.txOutputs.map((item) => { + const addressFromScript = addressConverter.fromOutputScript(item.script, network); + + return { + address: addressFromScript, + value: item.value, + }; + }); + + return mappedOutputs; + }, [network, parsedPsbt.txOutputs]); + + const errorMessage = useMemo(() => { + if (!currentInputs.some((item) => isEqualsIgnoringCase(item?.address, address))) { + return t('pages.Popup.Bitcoin.SignPsbt.entry.invalidSender'); + } + + if (currentOutputs.some((item) => !validate(item.address))) { + return t('pages.Popup.Bitcoin.SignPsbt.entry.invalidAddress'); + } + + if (gasRate === null) { + return t('pages.Popup.Bitcoin.SignPsbt.entry.failedLoadFee'); + } + + if (availableAmount === 0 || !canSendTx) { + return t('pages.Popup.Bitcoin.SignPsbt.entry.noAvailableAmount'); + } + + if (!totalInputAmount) { + return t('pages.Popup.Bitcoin.SignPsbt.entry.noAmount'); + } + + if (!psbtHex) { + return t('pages.Popup.Bitcoin.SignPsbt.entry.failedCreateTxHex'); + } + + return ''; + }, [address, availableAmount, canSendTx, currentInputs, currentOutputs, gasRate, psbtHex, t, totalInputAmount]); + + const handleChange = useCallback((_: React.SyntheticEvent, newTabValue: number) => { + setTabValue(newTabValue); + }, []); + + return ( + + + + + + + + + + + + + {t('pages.Popup.Bitcoin.SignPsbt.entry.input')} + + {currentInputs.map((item) => { + const displayValue = toDisplayDenomAmount(item.value, decimals); + + return ( + + + {shorterAddress(item.address, 14)} + + + + {displayValue} + + + + {currentBitcoinNetwork.displayDenom} + + + + ); + })} + + + + {t('pages.Popup.Bitcoin.SignPsbt.entry.output')} + + {currentOutputs.map((item) => { + const displayValue = toDisplayDenomAmount(item.value, decimals); + + return ( + + + {shorterAddress(item.address, 14)} + + + + {displayValue} + + + + {currentBitcoinNetwork.displayDenom} + + + + ); + })} + + + + + + + {t('pages.Popup.Bitcoin.SignPsbt.entry.expectedFee')} + + + + + + {displayFee} + + + {symbol} + + + + {displayFeePrice} + + + + + + + + + + + + + {errorMessage && ( + + + + + + {errorMessage} + + + )} + + { + responseToWeb({ + response: { + error: { + code: RPC_ERROR.USER_REJECTED_REQUEST, + message: `${RPC_ERROR_MESSAGE[RPC_ERROR.USER_REJECTED_REQUEST]}`, + }, + }, + message, + messageId, + origin, + }); + + await deQueue(); + }} + > + {t('pages.Popup.Bitcoin.SignPsbt.entry.cancel')} + + + + { + try { + setIsProgress(true); + + if (!keyPair?.privateKey) { + throw new Error('key does not exist'); + } + + const signedPsbt = parsedPsbt.signAllInputs(ecpairFromPrivateKey(keyPair.privateKey)); + const validatePsbtSignatures = signedPsbt.validateSignaturesOfAllInputs((pubkey, msghash, signature) => + ecpairFromPublicKey(pubkey).verify(msghash, signature), + ); + + if (!validatePsbtSignatures) { + throw new Error('Failed to sign transaction'); + } + + const txHex = signedPsbt.finalizeAllInputs().extractTransaction().toHex(); + + const response = await post( + currentBitcoinNetwork.rpcURL, + { + jsonrpc: '2.0', + id: '1', + method: 'sendrawtransaction', + params: [txHex], + }, + { headers: { 'Content-Type': 'application/json' } }, + ); + + if (!response.result) { + throw new Error('Failed to sign transaction'); + } + + const { result } = response; + + responseToWeb({ + response: { + result, + }, + message, + messageId, + origin, + }); + + await deQueue(); + } catch (e) { + enqueueSnackbar((e as { message: string }).message, { variant: 'error' }); + } finally { + setLoadingLedgerSigning(false); + setIsProgress(false); + } + }} + > + {t('pages.Popup.Bitcoin.SignPsbt.entry.sign')} + + + + + + + + ); +} diff --git a/src/Popup/pages/Popup/Bitcoin/SignPsbt/index.tsx b/src/Popup/pages/Popup/Bitcoin/SignPsbt/index.tsx new file mode 100644 index 000000000..48e6b90bd --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/SignPsbt/index.tsx @@ -0,0 +1,41 @@ +import { Suspense } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; + +import Empty from '~/Popup/components/common/Empty'; +import Lock from '~/Popup/components/Lock'; +import AccessRequest from '~/Popup/components/requests/AccessRequest'; +import LedgerPublicKeyRequest from '~/Popup/components/requests/LedgerPublicKeyRequest'; +import { useCurrentQueue } from '~/Popup/hooks/useCurrent/useCurrentQueue'; +import type { Queue } from '~/types/extensionStorage'; +import type { BitSignPsbt } from '~/types/message/bitcoin'; + +import Entry from './entry'; +import Layout from './layout'; + +export default function Send() { + const { currentQueue } = useCurrentQueue(); + + if (currentQueue && isBitcoinTransaction(currentQueue)) { + return ( + + + + + }> + + + + + + + + + ); + } + + return null; +} + +function isBitcoinTransaction(queue: Queue): queue is Queue { + return queue?.message?.method === 'bit_signPsbt'; +} diff --git a/src/Popup/pages/Popup/Bitcoin/SignPsbt/layout.tsx b/src/Popup/pages/Popup/Bitcoin/SignPsbt/layout.tsx new file mode 100644 index 000000000..27b2215f8 --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/SignPsbt/layout.tsx @@ -0,0 +1,9 @@ +import BaseLayout from '~/Popup/components/BaseLayout'; + +type LayoutProps = { + children: JSX.Element; +}; + +export default function Layout({ children }: LayoutProps) { + return {children}; +} diff --git a/src/Popup/pages/Popup/Bitcoin/SignPsbt/styled.tsx b/src/Popup/pages/Popup/Bitcoin/SignPsbt/styled.tsx new file mode 100644 index 000000000..d4c632562 --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/SignPsbt/styled.tsx @@ -0,0 +1,211 @@ +import { CircularProgress } from '@mui/material'; +import { styled } from '@mui/material/styles'; + +import { TabPanel } from '~/Popup/components/common/Tab'; + +export const Container = styled('div')({ + position: 'relative', + + height: '100%', +}); + +export const ContentContainer = styled('div')({ + height: 'calc(100% - 17.2rem)', + + padding: '1.2rem 1.6rem 0', +}); + +export const StyledTabPanel = styled(TabPanel)({ + marginTop: '1.6rem', + height: 'calc(100% - 4.8rem)', +}); + +export const FeeContainer = styled('div')(({ theme }) => ({ + marginTop: '0.8rem', + padding: '1.6rem', + border: `0.1rem solid ${theme.colors.base03}`, + + borderRadius: '0.8rem', +})); + +export const FeeInfoContainer = styled('div')({ + display: 'flex', + justifyContent: 'space-between', +}); + +export const FeeLeftContainer = styled('div')(({ theme }) => ({ + color: theme.colors.text02, + + display: 'flex', +})); + +export const FeeRightContainer = styled('div')({ + display: 'flex', + justifyContent: 'flex-end', +}); + +export const FeeRightColumnContainer = styled('div')({}); + +export const FeeRightAmountContainer = styled('div')(({ theme }) => ({ + display: 'flex', + justifyContent: 'flex-end', + + color: theme.colors.text01, +})); + +export const FeeRightValueContainer = styled('div')(({ theme }) => ({ + marginTop: '0.2rem', + + display: 'flex', + justifyContent: 'flex-end', + + color: theme.colors.text02, +})); + +export const FeeEditContainer = styled('div')({ + display: 'flex', + justifyContent: 'space-between', + + marginTop: '1.2rem', +}); + +export const FeeEditLeftContainer = styled('div')({ display: 'flex', justifyContent: 'flex-start' }); + +export const FeeEditRightContainer = styled('div')({ + display: 'flex', + justifyContent: 'flex-end', + + '& > :nth-of-type(n + 1)': { marginLeft: '0.4rem' }, +}); + +type FeeButtonProps = { + 'data-is-active'?: number; +}; + +export const FeeButton = styled('button')(({ theme, ...props }) => ({ + border: 0, + padding: '0.4rem 0', + + borderRadius: '5rem', + + minWidth: '5rem', + + backgroundColor: props['data-is-active'] ? theme.accentColors.purple01 : theme.colors.base03, + color: props['data-is-active'] ? theme.colors.text01 : theme.colors.text02, + + '& > svg': { + fill: props['data-is-active'] ? theme.colors.text01 : theme.colors.text02, + '& > path': { + fill: props['data-is-active'] ? theme.colors.text01 : theme.colors.text02, + }, + }, + + cursor: 'pointer', +})); + +export const FeeEditButton = styled(FeeButton)({ + minWidth: '2rem', + + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + + borderRadius: '10rem', + + padding: '0 0.4rem', +}); + +export const BottomContainer = styled('div')({ + position: 'absolute', + + bottom: '1.6rem', + left: '1.6rem', + + width: 'calc(100% - 3.2rem)', +}); + +export const BottomButtonContainer = styled('div')({ + display: 'grid', + gridTemplateColumns: '1fr 1fr', + columnGap: '0.8rem', +}); + +export const StyledCircularProgress = styled(CircularProgress)(({ theme }) => ({ + marginLeft: '0.4rem', + '&.MuiCircularProgress-root': { + color: theme.accentColors.green01, + }, +})); + +export const WarningContainer = styled('div')({ + padding: '1.2rem 2.2rem 1.2rem 1.6rem', + + wordBreak: 'break-all', + + borderRadius: '0.8rem', + + backgroundColor: 'rgba(205, 26, 26, 0.15)', + + display: 'flex', + + marginBottom: '0.8rem', +}); + +export const WarningIconContainer = styled('div')(({ theme }) => ({ + '& > svg': { + '& > path': { + fill: theme.accentColors.red, + }, + }, +})); + +export const WarningTextContainer = styled('div')(({ theme }) => ({ + marginLeft: '0.4rem', + color: theme.accentColors.red, +})); + +export const TxMessageContentContainer = styled('div')(({ theme }) => ({ + color: theme.colors.text01, + + margin: '0 -1.6rem', + padding: '1.2rem 1.6rem 0', + + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', + + overflow: 'auto', + + display: 'flex', + flexDirection: 'column', + rowGap: '0.6rem', +})); + +export const InOutputContainer = styled('div')({ + display: 'flex', + justifyContent: 'space-between', +}); + +export const AddressContainer = styled('div')(({ theme }) => ({ + color: theme.colors.text01, +})); + +export const AmountContainer = styled('div')({ + display: 'flex', + alignItems: 'center', + columnGap: '0.4rem', +}); + +export const SectionContainer = styled('div')({ + display: 'flex', + flexDirection: 'column', + + rowGap: '0.4rem', +}); + +export const DenomContainer = styled('div')(({ theme }) => ({ + color: theme.colors.text01, +})); + +export const LabelContainer = styled('div')(({ theme }) => ({ + color: theme.colors.text02, +})); diff --git a/src/Popup/pages/Popup/Bitcoin/SwitchNetwork/entry.tsx b/src/Popup/pages/Popup/Bitcoin/SwitchNetwork/entry.tsx new file mode 100644 index 000000000..40a36612d --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/SwitchNetwork/entry.tsx @@ -0,0 +1,120 @@ +import { Typography } from '@mui/material'; + +import { Network } from '~/constants/bitcoin'; +import { BITCOIN } from '~/constants/chain/bitcoin/bitcoin'; +import { SIGNET } from '~/constants/chain/bitcoin/signet'; +import { RPC_ERROR, RPC_ERROR_MESSAGE } from '~/constants/error'; +import Button from '~/Popup/components/common/Button'; +import OutlineButton from '~/Popup/components/common/OutlineButton'; +import { useCurrentBitcoinNetwork } from '~/Popup/hooks/useCurrent/useCurrentBitcoinNetwork'; +import { useCurrentQueue } from '~/Popup/hooks/useCurrent/useCurrentQueue'; +import { useTranslation } from '~/Popup/hooks/useTranslation'; +import { responseToWeb } from '~/Popup/utils/message'; +import type { Queue } from '~/types/extensionStorage'; +import type { BitSwitchNetwork } from '~/types/message/bitcoin'; + +import { + BottomButtonContainer, + BottomContainer, + ChainInfoContainer, + Container, + ContentContainer, + DescriptionContainer, + QuestionContainer, + StyledDivider, + SwitchIconContainer, +} from './styled'; +import Header from '../components/Header'; + +import Switch60Icon from '~/images/icons/Switch60.svg'; + +type EntryProps = { + queue: Queue; +}; + +export default function Entry({ queue }: EntryProps) { + const { deQueue } = useCurrentQueue(); + + const { bitcoinNetworks, currentBitcoinNetwork, setCurrentBitcoinNetwork } = useCurrentBitcoinNetwork(); + + const { t } = useTranslation(); + + const { message, messageId, origin } = queue; + + const requestNetwork = bitcoinNetworks.find((item) => { + const requestedNetworkType = message.params[0]; + + if (requestedNetworkType === 'mainnet') { + return item.id === BITCOIN.id; + } + + return item.id === SIGNET.id; + }); + + return ( + + + + + + + + {t('pages.Popup.Bitcoin.SwitchNetwork.entry.question')} + + + {t('pages.Popup.Bitcoin.SwitchNetwork.entry.description')} + + + + {requestNetwork?.chainName} + + + + + { + responseToWeb({ + response: { + error: { + code: RPC_ERROR.USER_REJECTED_REQUEST, + message: `${RPC_ERROR_MESSAGE[RPC_ERROR.USER_REJECTED_REQUEST]}`, + }, + }, + message, + messageId, + origin, + }); + + await deQueue(); + }} + > + {t('pages.Popup.Bitcoin.SwitchNetwork.entry.cancelButton')} + + { + if (requestNetwork) { + await setCurrentBitcoinNetwork(requestNetwork); + } + + const currentNetwork: Network = requestNetwork?.isSignet ? Network.SIGNET : Network.MAINNET; + + const result = currentNetwork; + + responseToWeb({ + response: { + result, + }, + message, + messageId, + origin, + }); + await deQueue(); + }} + > + {t('pages.Popup.Bitcoin.SwitchNetwork.entry.switchButton')} + + + + + ); +} diff --git a/src/Popup/pages/Popup/Bitcoin/SwitchNetwork/index.tsx b/src/Popup/pages/Popup/Bitcoin/SwitchNetwork/index.tsx new file mode 100644 index 000000000..586ecf497 --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/SwitchNetwork/index.tsx @@ -0,0 +1,29 @@ +import Lock from '~/Popup/components/Lock'; +import AccessRequest from '~/Popup/components/requests/AccessRequest'; +import { useCurrentQueue } from '~/Popup/hooks/useCurrent/useCurrentQueue'; +import type { Queue } from '~/types/extensionStorage'; +import type { BitSwitchNetwork } from '~/types/message/bitcoin'; + +import Entry from './entry'; +import Layout from './layout'; + +export default function SwitchNetwork() { + const { currentQueue } = useCurrentQueue(); + + if (currentQueue && isSwitchNetwork(currentQueue)) { + return ( + + + + + + + + ); + } + return null; +} + +function isSwitchNetwork(queue: Queue): queue is Queue { + return queue?.message?.method === 'bitc_switchNetwork'; +} diff --git a/src/Popup/pages/Popup/Bitcoin/SwitchNetwork/layout.tsx b/src/Popup/pages/Popup/Bitcoin/SwitchNetwork/layout.tsx new file mode 100644 index 000000000..27b2215f8 --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/SwitchNetwork/layout.tsx @@ -0,0 +1,9 @@ +import BaseLayout from '~/Popup/components/BaseLayout'; + +type LayoutProps = { + children: JSX.Element; +}; + +export default function Layout({ children }: LayoutProps) { + return {children}; +} diff --git a/src/Popup/pages/Popup/Bitcoin/SwitchNetwork/styled.tsx b/src/Popup/pages/Popup/Bitcoin/SwitchNetwork/styled.tsx new file mode 100644 index 000000000..1659a9066 --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/SwitchNetwork/styled.tsx @@ -0,0 +1,72 @@ +import { styled } from '@mui/material/styles'; + +import Divider from '~/Popup/components/common/Divider'; + +export const Container = styled('div')({ + position: 'relative', + + height: '100%', +}); + +export const SwitchIconContainer = styled('div')({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}); + +export const QuestionContainer = styled('div')(({ theme }) => ({ + marginTop: '1.2rem', + color: theme.colors.text01, + + textAlign: 'center', + + wordBreak: 'break-word', + + padding: '0 4rem', +})); + +export const DescriptionContainer = styled('div')(({ theme }) => ({ + marginTop: '1rem', + color: theme.colors.text02, + + textAlign: 'center', + + wordBreak: 'break-word', + + padding: '0 2rem', +})); + +export const ChainInfoContainer = styled('div')(({ theme }) => ({ + textAlign: 'center', + padding: '2rem 3rem', + + backgroundColor: theme.colors.base02, + color: theme.colors.text01, + + borderRadius: '0.8rem', +})); + +export const StyledDivider = styled(Divider)({ + margin: '2rem 0', +}); + +export const ContentContainer = styled('div')({ + height: 'calc(100% - 16.8rem)', + + padding: '2rem 1.6rem 0', +}); + +export const BottomContainer = styled('div')({ + position: 'absolute', + + bottom: '1.6rem', + left: '1.6rem', + + width: 'calc(100% - 3.2rem)', +}); + +export const BottomButtonContainer = styled('div')({ + display: 'grid', + gridTemplateColumns: '1fr 1fr', + columnGap: '0.8rem', +}); diff --git a/src/Popup/pages/Popup/Bitcoin/components/Header/index.tsx b/src/Popup/pages/Popup/Bitcoin/components/Header/index.tsx new file mode 100644 index 000000000..c6bbdd5ea --- /dev/null +++ b/src/Popup/pages/Popup/Bitcoin/components/Header/index.tsx @@ -0,0 +1,40 @@ +import { BITCOIN } from '~/constants/chain/bitcoin/bitcoin'; +import PopupHeader from '~/Popup/components/PopupHeader'; +import { useCurrentAccount } from '~/Popup/hooks/useCurrent/useCurrentAccount'; +import { useCurrentPassword } from '~/Popup/hooks/useCurrent/useCurrentPassword'; +import { getAddress, getKeyPair } from '~/Popup/utils/common'; +import type { BitcoinChain } from '~/types/chain'; + +type HeaderProps = { + network?: BitcoinChain; + origin?: string; + className?: string; +}; + +export default function Header({ network, origin, className }: HeaderProps) { + const chain = BITCOIN; + const { currentAccount } = useCurrentAccount(); + + const { currentPassword } = useCurrentPassword(); + + const keyPair = getKeyPair(currentAccount, chain, currentPassword); + const address = getAddress(chain, keyPair?.publicKey); + + const chainName = (() => { + if (network) { + return network.chainName; + } + + return chain.chainName; + })(); + + const chainImageURL = (() => { + if (network) { + return network.imageURL; + } + + return chain.imageURL; + })(); + + return ; +} diff --git a/src/Popup/pages/Popup/RequestAccount/entry.tsx b/src/Popup/pages/Popup/RequestAccount/entry.tsx index 2dfd5f67c..04ae49905 100644 --- a/src/Popup/pages/Popup/RequestAccount/entry.tsx +++ b/src/Popup/pages/Popup/RequestAccount/entry.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { CHAINS } from '~/constants/chain'; import { APTOS } from '~/constants/chain/aptos/aptos'; +import { BITCOIN } from '~/constants/chain/bitcoin/bitcoin'; import { ETHEREUM } from '~/constants/chain/ethereum/ethereum'; import { SUI } from '~/constants/chain/sui/sui'; import { useCurrentAccount } from '~/Popup/hooks/useCurrent/useCurrentAccount'; @@ -12,6 +13,7 @@ import { getAddress, getKeyPair } from '~/Popup/utils/common'; import { responseToWeb } from '~/Popup/utils/message'; import type { CosmosChain } from '~/types/chain'; import type { AptosAccountResponse } from '~/types/message/aptos'; +import type { BitRequestAccountResponse } from '~/types/message/bitcoin'; import type { CosRequestAccountResponse } from '~/types/message/cosmos'; import type { EthRequestAccountsResponse } from '~/types/message/ethereum'; import type { SuiConnectResponse, SuiGetAccountResponse } from '~/types/message/sui'; @@ -144,6 +146,30 @@ export default function Entry() { void deQueue(); } + + if (currentQueue?.message.method === 'bit_requestAccount' && currentPassword) { + const { message, messageId, origin } = currentQueue; + + const chain = BITCOIN; + + if (chain) { + const keyPair = getKeyPair(currentAccount, chain, currentPassword); + const address = getAddress(chain, keyPair?.publicKey); + + const result: BitRequestAccountResponse = [address]; + + responseToWeb({ + response: { + result, + }, + message, + messageId, + origin, + }); + + void deQueue(); + } + } }, [additionalChains, currentAccount, currentPassword, currentQueue, deQueue]); return null; } diff --git a/src/Popup/recoils/extensionStorage.ts b/src/Popup/recoils/extensionStorage.ts index 34309a137..1c5264a35 100644 --- a/src/Popup/recoils/extensionStorage.ts +++ b/src/Popup/recoils/extensionStorage.ts @@ -43,6 +43,7 @@ export const extensionStorageDefault: ExtensionStorage = { selectedEthereumNetworkId: '', selectedAptosNetworkId: '', selectedSuiNetworkId: '', + selectedBitcoinChainId: '', suiPermissions: [], diff --git a/src/Popup/utils/bitcoin.ts b/src/Popup/utils/bitcoin.ts index ef95cfa0c..f3ffe693e 100644 --- a/src/Popup/utils/bitcoin.ts +++ b/src/Popup/utils/bitcoin.ts @@ -1,7 +1,22 @@ import type { Network } from 'bitcoinjs-lib'; -import { payments } from 'bitcoinjs-lib'; +import { payments, Psbt } from 'bitcoinjs-lib'; export function getAddress(publicKey: Buffer, network?: Network) { const p2wpkh = payments.p2wpkh({ pubkey: publicKey, network }); return p2wpkh.address!; } + +export function formatPsbtHex(psbtHex: string) { + let formatData = ''; + try { + if (!/^[0-9a-fA-F]+$/.test(psbtHex)) { + formatData = Psbt.fromBase64(psbtHex).toHex(); + } else { + Psbt.fromHex(psbtHex); + formatData = psbtHex; + } + } catch (e) { + throw new Error('invalid psbt'); + } + return formatData; +} diff --git a/src/Popup/utils/crypto.ts b/src/Popup/utils/crypto.ts index 1d7c21c35..6bf7cc607 100644 --- a/src/Popup/utils/crypto.ts +++ b/src/Popup/utils/crypto.ts @@ -16,6 +16,10 @@ export function ecpairFromPrivateKey(privateKey: Buffer) { return ECPair.fromPrivateKey(privateKey); } +export function ecpairFromPublicKey(publicKey: Buffer) { + return ECPair.fromPublicKey(publicKey); +} + export function sha512(message: string) { return baseSha512(message).toString(encHex); } diff --git a/src/Popup/utils/error.ts b/src/Popup/utils/error.ts index bd8220908..45a535f61 100644 --- a/src/Popup/utils/error.ts +++ b/src/Popup/utils/error.ts @@ -102,6 +102,32 @@ export class SuiRPCError extends Error { } } +export class BitcoinRPCError extends Error { + public code: number; + + public id?: string | number; + + public rpcMessage: unknown; + + constructor(code: number, message: string, id?: string | number) { + super(message); + this.name = 'BitcoinRPCError'; + this.code = code; + this.id = id; + + const errorMessage = { + error: { + code, + message, + }, + }; + + this.rpcMessage = errorMessage; + + Object.setPrototypeOf(this, SuiRPCError.prototype); + } +} + export class CommonRPCError extends Error { public code: number; diff --git a/src/Popup/utils/extensionStorage.ts b/src/Popup/utils/extensionStorage.ts index 9777d68bd..730e25359 100644 --- a/src/Popup/utils/extensionStorage.ts +++ b/src/Popup/utils/extensionStorage.ts @@ -1,4 +1,4 @@ -import { APTOS_NETWORKS, CHAINS, ETHEREUM_NETWORKS, SUI_NETWORKS } from '~/constants/chain'; +import { APTOS_NETWORKS, BITCOIN_CHAINS, CHAINS, ETHEREUM_NETWORKS, SUI_NETWORKS } from '~/constants/chain'; import { extensionStorageDefault } from '~/Popup/recoils/extensionStorage'; import type { ExtensionStorage, ExtensionStorageKeys } from '~/types/extensionStorage'; @@ -66,6 +66,7 @@ export async function extensionStorage() { additionalSuiNetworks, selectedAptosNetworkId, selectedSuiNetworkId, + selectedBitcoinChainId, } = storageWithDefault; const currentAccount = (() => accounts.find((account) => account.id === selectedAccountId)!)(); @@ -95,6 +96,14 @@ export async function extensionStorage() { return suiNetworks.find((network) => network.id === networkId) ?? suiNetworks[0]; })(); + const currentBitcoinNetwork = (() => { + const bitcoinNetworks = [...BITCOIN_CHAINS]; + + const networkId = selectedBitcoinChainId ?? BITCOIN_CHAINS[0].id; + + return bitcoinNetworks.find((network) => network.id === networkId) ?? bitcoinNetworks[0]; + })(); + const currentAllowedChains = CHAINS.filter((chain) => allowedChainIds.includes(chain.id)); const currentAccountAllowedOrigins = allowedOrigins @@ -108,6 +117,7 @@ export async function extensionStorage() { currentEthereumNetwork, currentAptosNetwork, currentSuiNetwork, + currentBitcoinNetwork, currentAllowedChains, currentAccountAllowedOrigins, }; diff --git a/src/Scripts/contentScript.ts b/src/Scripts/contentScript.ts index 0f1ca212a..f73aae89c 100644 --- a/src/Scripts/contentScript.ts +++ b/src/Scripts/contentScript.ts @@ -1,4 +1,4 @@ -import { APTOS_LISTENER_TYPE, COSMOS_LISTENER_TYPE, ETHEREUM_LISTENER_TYPE, MESSAGE_TYPE, SUI_LISTENER_TYPE } from '~/constants/message'; +import { APTOS_LISTENER_TYPE, BITCOIN_LISTENER_TYPE, COSMOS_LISTENER_TYPE, ETHEREUM_LISTENER_TYPE, MESSAGE_TYPE, SUI_LISTENER_TYPE } from '~/constants/message'; import { extension } from '~/Popup/utils/extension'; import type { BackgroundToContentScriptEventMessage, @@ -59,6 +59,7 @@ extension.runtime.onMessage.addListener((request: ListenerMessage + new Promise((res, rej) => { + const messageId = uuidv4(); + + const handler = (event: MessageEvent>) => { + if (event.data?.isCosmostation && event.data?.type === MESSAGE_TYPE.RESPONSE__WEB_TO_CONTENT_SCRIPT && event.data?.messageId === messageId) { + window.removeEventListener('message', handler); + + const { data } = event; + + if (data.response?.error) { + rej(data.response.error); + } else { + res(data.response.result); + } + } + }; + + window.addEventListener('message', handler); + + window.postMessage({ + isCosmostation: true, + line: LINE_TYPE.BITCOIN, + type: MESSAGE_TYPE.REQUEST__WEB_TO_CONTENT_SCRIPT, + messageId, + message, + }); + }); + +const connectWallet = async () => { + const addressList = (await request({ method: 'bit_requestAccount', params: undefined })) as BitRequestAccountResponse; + return addressList; +}; + +const getAccounts = async () => { + const address = (await request({ method: 'bit_getAddress', params: undefined })) as BitGetAddressResponse; + return [address]; +}; + +const getAddress = async () => { + const address = (await request({ method: 'bit_getAddress', params: undefined })) as BitGetAddressResponse; + return address; +}; + +const getBalance = async () => { + const balance = (await request({ method: 'bit_getBalance', params: undefined })) as BitGetBalanceResponse; + return balance; +}; + +const getPublicKeyHex = async () => { + const publicKeyHex = (await request({ method: 'bit_getPublicKeyHex', params: undefined })) as string; + return publicKeyHex; +}; + +const getPublicKey = async () => { + const publicKeyHex = (await request({ method: 'bit_getPublicKeyHex', params: undefined })) as string; + return publicKeyHex; +}; + +const signPsbt = async (psbtHex: string) => { + const formattedPsbt = formatPsbtHex(psbtHex); + + const signedPsbt = (await request({ method: 'bit_signPsbt', params: formattedPsbt })) as BitSignPsbtResposne; + return signedPsbt; +}; + +// const signPsbts = async (psbtsHexes: string[]) => { +// const signedPsbts = (await request({ method: 'bit_signPsbts', params: psbtsHexes })) as BitSignPsbtsResposne; +// return signedPsbts; +// }; + +// const signMessage = async (message: string, type?: 'ecdsa' | 'bip322-simple') => { +// const signedMessage = (await request({ method: 'bit_signMessage', params: { message, type } })) as string; +// return signedMessage; +// }; + +// const signMessageBIP322 = async (message: string) => { +// const signedMessage = (await request({ method: 'bit_signMessage', params: { message, type: 'bip322-simple' } })) as string; +// return signedMessage; +// }; + +const switchNetwork = async (network: Network) => { + const response = (await request({ method: 'bit_switchNetwork', params: [network] })) as Network; + + return response; +}; + +const getNetwork = async () => { + const network = (await request({ method: 'bit_getNetwork', params: undefined })) as Network; + + return network; +}; + +const sendBitcoin = async (to: string, satAmount: number) => { + const txId = (await request({ method: 'bit_sendBitcoin', params: { to, satAmount } })) as BitSendBitcoinResponse; + return txId; +}; + +const pushTx = async (txHex: string) => { + const txId = (await request({ method: 'bit_pushTx', params: [txHex] })) as string; + return txId; +}; + +export const on = (eventName: BitcoinListenerType, eventHandler: (data: unknown) => void) => { + const handler = (event: MessageEvent) => { + if (event.data?.isCosmostation && event.data?.type === eventName && event.data?.line === 'BITCOIN') { + eventHandler(event.data?.message); + } + }; + + window.addEventListener('message', handler); + + window.cosmostation.handlerInfos.push({ line: 'BITCOIN', eventName, originHandler: eventHandler, handler }); + + return handler; +}; + +const off = (eventName: BitcoinListenerType, eventHandler: (data: unknown) => void) => { + const handlerInfos = window.cosmostation.handlerInfos.filter( + (item) => item.line === 'BITCOIN' && item.eventName === eventName && item.originHandler === eventHandler, + ); + const notHandlerInfos = window.cosmostation.handlerInfos.filter( + (item) => !(item.line === 'BITCOIN' && item.eventName === eventName && item.originHandler === eventHandler), + ); + + handlerInfos.forEach((handlerInfo) => { + window.removeEventListener('message', handlerInfo.handler); + }); + + window.cosmostation.handlerInfos = notHandlerInfos; +}; + +// eslint-disable-next-line @typescript-eslint/require-await +const getWalletProviderName = async () => COSMOSTATION_WALLET_NAME; +// eslint-disable-next-line @typescript-eslint/require-await +const getWalletProviderIcon = async () => COSMOSTATION_ENCODED_LOGO_IMAGE; + +export const bitcoin = { + connectWallet, + getWalletProviderName, + getWalletProviderIcon, + getAddress, + getAccounts, + getBalance, + getPublicKeyHex, + getPublicKey, + signPsbt, + // signPsbts, + getNetwork, + // signMessageBIP322, + // signMessage, + on, + off, + switchNetwork, + sendBitcoin, + pushTx, +}; diff --git a/src/Scripts/injectScript/index.ts b/src/Scripts/injectScript/index.ts index 50c5ca7e1..b2348ff9d 100644 --- a/src/Scripts/injectScript/index.ts +++ b/src/Scripts/injectScript/index.ts @@ -5,6 +5,7 @@ import type { ListenerMessage } from '~/types/message'; import type { ComProvidersResponse } from '~/types/message/common'; import { aptos } from './aptos'; +import { bitcoin } from './bitcoin'; import { common } from './common'; import { cosmos, cosmosWallet, keplr, tendermint } from './cosmos'; import { announceEip6963Provider, ethereum } from './ethereum'; @@ -18,6 +19,7 @@ void (() => { common, ethereum, cosmos, + bitcoin, aptos, tendermint, sui, diff --git a/src/constants/bitcoin.ts b/src/constants/bitcoin.ts index 0d5c52378..740e245b0 100644 --- a/src/constants/bitcoin.ts +++ b/src/constants/bitcoin.ts @@ -3,3 +3,9 @@ export const P2WPKH__V_BYTES = { INPUT: 68, OUTPUT: 31, }; + +export enum Network { + MAINNET = 'mainnet', + TESTNET = 'testnet', + SIGNET = 'signet', +} diff --git a/src/constants/error.ts b/src/constants/error.ts index 48f42fc8f..7bdb5fbf5 100644 --- a/src/constants/error.ts +++ b/src/constants/error.ts @@ -75,6 +75,10 @@ export const SUI_RPC_ERROR_MESSAGE = { [RPC_ERROR.LEDGER_UNSUPPORTED_CHAIN]: 'The chain is not supported by the ledger account.', } as const; +export const BITCOIN_RPC_ERROR_MESSAGE = { + [RPC_ERROR.UNAUTHORIZED]: 'The requested account and/or method has not been authorized by the user.', +} as const; + export const ETHEREUM_ADD_NFT_ERROR = { INVALID_CONTRACT_ADDRESS: 1, INVALID_TOKEN_ID: 2, diff --git a/src/constants/message/bitcoin.ts b/src/constants/message/bitcoin.ts index 77545b462..ff092560d 100644 --- a/src/constants/message/bitcoin.ts +++ b/src/constants/message/bitcoin.ts @@ -1,8 +1,23 @@ export const BITCOIN_POPUP_METHOD_TYPE = { BIT__SIGN_AND_SEND_TRANSACTION: 'bit_signAndSendTransaction', + BIT__SWITCH_NETWORK: 'bit_switchNetwork', + BIT__SEND_BITCOIN: 'bit_sendBitcoin', + BIT__SIGN_MESSAGE: 'bit_signMessage', + BIT__SIGN_PSBT: 'bit_signPsbt', + BIT__SIGN_PSBTS: 'bit_signPsbts', + BIT__REQUEST_ACCOUNT: 'bit_requestAccount', + + BITC__SWITCH_NETWORK: 'bitc_switchNetwork', } as const; -export const BITCOIN_NO_POPUP_METHOD_TYPE = {} as const; +export const BITCOIN_NO_POPUP_METHOD_TYPE = { + BIT__GET_ADDRESS: 'bit_getAddress', + BIT__GET_NETWORK: 'bit_getNetwork', + BIT_GET_PUBLICKKEY_HEX: 'bit_getPublicKeyHex', + BIT_GET_BALANCE: 'bit_getBalance', + BIT_GET_INSCRIPTIONS: 'bit_getInscriptions', + BIT__PUSH_TX: 'bit_pushTx', +} as const; export const BITCOIN_METHOD_TYPE = { ...BITCOIN_POPUP_METHOD_TYPE, diff --git a/src/constants/message/index.ts b/src/constants/message/index.ts index 5979682b5..74d7d5b4d 100644 --- a/src/constants/message/index.ts +++ b/src/constants/message/index.ts @@ -25,3 +25,7 @@ export const SUI_LISTENER_TYPE = { ACCOUNT_CHANGED: 'accountChange', CHAIN_CHANGED: 'networkChange', } as const; + +export const BITCOIN_LISTENER_TYPE = { + ACCOUNT_CHANGED: 'accountChanged', +} as const; diff --git a/src/constants/route.ts b/src/constants/route.ts index 0ec695e99..42103c2f4 100644 --- a/src/constants/route.ts +++ b/src/constants/route.ts @@ -97,5 +97,11 @@ export const PATH = { POPUP__SUI__TRANSACTION: '/popup/sui/transaction', POPUP__SUI__SIGN_MESSAGE: '/popup/sui/sign-message', + // popup bitcoin + POPUP__BITCOIN__SIGN_MESSAGE: '/popup/bitcoin/sign-message', + POPUP__BITCOIN__SWITCH_NETWORK: '/popup/bitcoin/switch-network', + POPUP__BITCOIN__SEND_BITCOIN: '/popup/bitcoin/send', + POPUP__BITCOIN__SIGN_PSBT: '/popup/bitcoin/sign-psbt', + POPUP__TX_RECEIPT: '/popup/tx-receipt', } as const; diff --git a/src/types/d.ts/window.d.ts b/src/types/d.ts/window.d.ts index 08efc56fd..9e76d9cc1 100644 --- a/src/types/d.ts/window.d.ts +++ b/src/types/d.ts/window.d.ts @@ -25,6 +25,7 @@ interface Window { cosmos: Cosmos; aptos: Aptos; sui: Sui; + bitcoin: Bitcoin; tendermint: { request: (message: import('~/types/message').CosmosRequestMessage) => Promise; on: (eventName: import('~/types/message').CosmosListenerType, eventHandler: (event?: unknown) => void) => void; @@ -135,3 +136,24 @@ type Sui = { }; type MetaMask = Ethereum; + +type Bitcoin = { + connectWallet: () => Promise; + getWalletProviderName: () => Promise; + getWalletProviderIcon: () => Promise; + getAddress: () => Promise; + getAccounts: () => Promise; + getBalance: () => Promise; + getPublicKey: () => Promise; + getPublicKeyHex: () => Promise; + signPsbt: (psbtHex: string) => Promise; + // signPsbts: (psbtHexs: string[]) => Promise; + getNetwork: () => Promise; + // signMessage: (message: string, type?: 'ecdsa' | 'bip322-simple') => Promise; + // signMessageBIP322: (message: string) => Promise; + switchNetwork: (network: import('~/constants/bitcoin').Network) => Promise; + sendBitcoin: (to: string, satAmount: number) => Promise; + pushTx: (txHex: string) => Promise; + on: (eventName: import('~/types/message').BitcoinListenerType, callBack: () => void) => void; + off: (eventName: import('~/types/message').BitcoinListenerType, callBack: () => void) => void; +}; diff --git a/src/types/extensionStorage.ts b/src/types/extensionStorage.ts index 3ecf052f4..79129a2d6 100644 --- a/src/types/extensionStorage.ts +++ b/src/types/extensionStorage.ts @@ -1,6 +1,6 @@ import type { ACCOUNT_TYPE, CURRENCY_TYPE, LANGUAGE_TYPE } from '~/constants/extensionStorage'; import type { PERMISSION } from '~/constants/sui'; -import type { AptosNetwork, BIP44, Chain, CommonChain, CosmosToken, EthereumNetwork, EthereumToken, SuiNetwork } from '~/types/chain'; +import type { AptosNetwork, BIP44, BitcoinChain, Chain, CommonChain, CosmosToken, EthereumNetwork, EthereumToken, SuiNetwork } from '~/types/chain'; import type { TransportType } from '~/types/ledger'; import type { Path } from '~/types/route'; import type { ThemeType } from '~/types/theme'; @@ -126,6 +126,8 @@ export type ExtensionStorage = { additionalSuiNetworks: SuiNetwork[]; selectedSuiNetworkId: SuiNetwork['id']; + selectedBitcoinChainId: BitcoinChain['id']; + cosmosTokens: CosmosToken[]; ethereumTokens: EthereumToken[]; diff --git a/src/types/message/bitcoin.ts b/src/types/message/bitcoin.ts index 378657e75..d13e15f25 100644 --- a/src/types/message/bitcoin.ts +++ b/src/types/message/bitcoin.ts @@ -1,4 +1,103 @@ -import type { BITCOIN_POPUP_METHOD_TYPE } from '~/constants/message/bitcoin'; +import type { Network } from '~/constants/bitcoin'; +import type { BITCOIN_NO_POPUP_METHOD_TYPE, BITCOIN_POPUP_METHOD_TYPE } from '~/constants/message/bitcoin'; + +export type BitRequestAccountResponse = string[]; + +export type BitRequestAccount = { + method: typeof BITCOIN_POPUP_METHOD_TYPE.BIT__REQUEST_ACCOUNT; + params?: undefined; + id?: string | number; +}; + +export type BitGetAddressResponse = string; + +export type BitGetAddress = { + method: typeof BITCOIN_NO_POPUP_METHOD_TYPE.BIT__GET_ADDRESS; + params?: undefined; + id?: string | number; +}; + +export type BitSwitchNetwork = { + method: typeof BITCOIN_POPUP_METHOD_TYPE.BIT__SWITCH_NETWORK; + params: [Network]; + id?: string | number; +}; + +export type BitcSwitchNetwork = { + method: typeof BITCOIN_POPUP_METHOD_TYPE.BITC__SWITCH_NETWORK; + params: [Network]; + id?: string | number; +}; + +export type BitGetNetwork = { + method: typeof BITCOIN_NO_POPUP_METHOD_TYPE.BIT__GET_NETWORK; + params?: undefined; + id?: string | number; +}; + +export type BitGetPublicKeyHex = { + method: typeof BITCOIN_NO_POPUP_METHOD_TYPE.BIT_GET_PUBLICKKEY_HEX; + params?: undefined; + id?: string | number; +}; + +export type BitGetBalanceResponse = number; + +export type BitGetBalance = { + method: typeof BITCOIN_NO_POPUP_METHOD_TYPE.BIT_GET_BALANCE; + params?: undefined; + id?: string | number; +}; + +export type BitPushTxResponse = string; + +export type BitPushTx = { + method: typeof BITCOIN_NO_POPUP_METHOD_TYPE.BIT__PUSH_TX; + params: [string]; + id?: string | number; +}; + +export type BitSendBitcoinParams = { + to: string; + satAmount: number; +}; +export type BitSendBitcoinResponse = string; + +export type BitSendBitcoin = { + method: typeof BITCOIN_POPUP_METHOD_TYPE.BIT__SEND_BITCOIN; + params: BitSendBitcoinParams; + id?: string | number; +}; + +export type BitSignMessageParams = { + message: string; + type?: 'ecdsa' | 'bip322-simple'; +}; +export type BitSignMessageResposne = string; + +export type BitSignMessage = { + method: typeof BITCOIN_POPUP_METHOD_TYPE.BIT__SIGN_MESSAGE; + params: BitSignMessageParams; + id?: string | number; +}; + +export type BitSignPsbtParams = string; +export type BitSignPsbtResposne = string; + +export type BitSignPsbt = { + method: typeof BITCOIN_POPUP_METHOD_TYPE.BIT__SIGN_PSBT; + params: BitSignPsbtParams; + id?: string | number; +}; + +export type BitSignPsbtsParams = string[]; +export type BitSignPsbtsResposne = string[]; + +export type BitSignPsbts = { + method: typeof BITCOIN_POPUP_METHOD_TYPE.BIT__SIGN_PSBTS; + params: BitSignPsbtsParams; + id?: string | number; +}; export type BitSignAndSendTransaction = { method: typeof BITCOIN_POPUP_METHOD_TYPE.BIT__SIGN_AND_SEND_TRANSACTION; diff --git a/src/types/message/index.ts b/src/types/message/index.ts index 69d4c5914..b9788be51 100644 --- a/src/types/message/index.ts +++ b/src/types/message/index.ts @@ -1,4 +1,4 @@ -import type { APTOS_LISTENER_TYPE, COSMOS_LISTENER_TYPE, ETHEREUM_LISTENER_TYPE, MESSAGE_TYPE } from '~/constants/message'; +import type { APTOS_LISTENER_TYPE, BITCOIN_LISTENER_TYPE, COSMOS_LISTENER_TYPE, ETHEREUM_LISTENER_TYPE, MESSAGE_TYPE } from '~/constants/message'; import type { LineType } from '~/types/chain'; import type { @@ -11,7 +11,21 @@ import type { AptosSignMessage, AptosSignTransaction, } from './aptos'; -import type { BitSignAndSendTransaction } from './bitcoin'; +import type { + BitcSwitchNetwork, + BitGetAddress, + BitGetBalance, + BitGetNetwork, + BitGetPublicKeyHex, + BitPushTx, + BitRequestAccount, + BitSendBitcoin, + BitSignAndSendTransaction, + BitSignMessage, + BitSignPsbt, + BitSignPsbts, + BitSwitchNetwork, +} from './bitcoin'; import type { ComProviders } from './common'; import type { CosAccount, @@ -70,7 +84,8 @@ export type CosmosListenerType = ValueOf; export type EthereumListenerType = ValueOf; export type AptosListenerType = ValueOf; export type SuiListenerType = ValueOf; -export type ListenerType = CosmosListenerType | EthereumListenerType | AptosListenerType; +export type BitcoinListenerType = ValueOf; +export type ListenerType = CosmosListenerType | EthereumListenerType | AptosListenerType | BitcoinListenerType; /** Web Page <-> Content Script 통신 타입 정의 */ export type ResponseMessage = { @@ -139,7 +154,20 @@ export type SuiRequestMessage = | SuiDisconnect | SuiGetChain; -export type BitcoinRequestMessage = BitSignAndSendTransaction; +export type BitcoinRequestMessage = + | BitSignAndSendTransaction + | BitRequestAccount + | BitGetAddress + | BitSwitchNetwork + | BitcSwitchNetwork + | BitGetNetwork + | BitGetPublicKeyHex + | BitGetBalance + | BitPushTx + | BitSendBitcoin + | BitSignMessage + | BitSignPsbt + | BitSignPsbts; export type CommonRequestMessage = ComProviders;