diff --git a/.github/workflows/push-koni-dev.yml b/.github/workflows/push-koni-dev.yml index 179935c00e2..2be4de3e6b1 100644 --- a/.github/workflows/push-koni-dev.yml +++ b/.github/workflows/push-koni-dev.yml @@ -5,6 +5,9 @@ on: - koni-dev - upgrade-ui - subwallet-dev + - koni/dev/issue-4200-v2 + - koni/dev/issue-4297 + - koni/dev/issue-4263 push: branches: - koni-dev @@ -72,6 +75,7 @@ jobs: TARGET_BRANCH: ${{ github.event.pull_request.base.ref }} CURRENT_BRANCH: ${{ github.event.pull_request.head.ref || github.ref }} BRANCH_NAME: ${{ github.ref_name }} + BTC_SERVICE_TOKEN: ${{ secrets.BTC_SERVICE_TOKEN }} run: | yarn install --immutable | grep -v 'YN0013' yarn build:koni-dev diff --git a/.github/workflows/push-master.yml b/.github/workflows/push-master.yml index 7819f818545..3bce39f8b4f 100644 --- a/.github/workflows/push-master.yml +++ b/.github/workflows/push-master.yml @@ -30,6 +30,7 @@ jobs: COINBASE_PAY_ID: ${{ secrets.COINBASE_PAY_ID }} MELD_WIZARD_KEY: ${{ secrets.MELD_WIZARD_KEY }} BRANCH_NAME: ${{ github.ref_name }} + BTC_SERVICE_TOKEN: ${{ secrets.BTC_SERVICE_TOKEN }} run: | yarn install --immutable | grep -v 'YN0013' yarn ${{ matrix.step }} diff --git a/.github/workflows/push-web-runner.yml b/.github/workflows/push-web-runner.yml index fbf681d2374..e7ef164eee8 100644 --- a/.github/workflows/push-web-runner.yml +++ b/.github/workflows/push-web-runner.yml @@ -34,6 +34,7 @@ jobs: COINBASE_PAY_ID: ${{ secrets.COINBASE_PAY_ID }} MELD_WIZARD_KEY: ${{ secrets.MELD_WIZARD_KEY }} BRANCH_NAME: master + BTC_SERVICE_TOKEN: ${{ secrets.BTC_SERVICE_TOKEN }} run: | yarn install --immutable | grep -v 'YN0013' yarn ${{ matrix.step }} diff --git a/.github/workflows/push-webapp.yml b/.github/workflows/push-webapp.yml index a0daf314d8c..2a35c632e3b 100644 --- a/.github/workflows/push-webapp.yml +++ b/.github/workflows/push-webapp.yml @@ -34,6 +34,7 @@ jobs: NFT_MINTING_HOST: ${{ secrets.NFT_MINTING_HOST }} MELD_WIZARD_KEY: ${{ secrets.MELD_WIZARD_KEY }} BRANCH_NAME: ${{ github.ref_name }} + BTC_SERVICE_TOKEN: ${{ secrets.BTC_SERVICE_TOKEN }} run: | yarn install --immutable | grep -v 'YN0013' if [ ${{ github.ref_name }} == 'webapp-dev' ]; then diff --git a/package.json b/package.json index d4d18bb7030..902560ca436 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ }, "dependencies": { "@types/node": "^17.0.10", + "bitcoinjs-lib": "6.1.5", "dexie": "^3.2.2", "loglevel": "^1.8.1", "react-markdown": "^9.0.1", @@ -110,6 +111,7 @@ "@subwallet/keyring": "^0.1.12", "@subwallet/react-ui": "5.1.2-b79", "@subwallet/ui-keyring": "^0.1.12", + "bitcoinjs-lib": "6.1.5", "@types/bn.js": "^5.1.6", "@zondax/ledger-substrate": "1.0.1", "babel-core": "^7.0.0-bridge.0", diff --git a/packages/extension-base/package.json b/packages/extension-base/package.json index 14a012e3142..2f0dc56cd71 100644 --- a/packages/extension-base/package.json +++ b/packages/extension-base/package.json @@ -72,11 +72,12 @@ "avail-js-sdk": "^0.2.12", "axios": "^1.6.2", "bignumber.js": "^9.1.1", + "bitcoinjs-lib": "6.1.5", "bn.js": "^5.2.1", "bowser": "^2.11.0", "browser-passworder": "^2.0.3", "buffer": "^6.0.3", - "cross-fetch": "^3.1.5", + "cross-fetch": "^4.1.0", "dexie": "^3.2.2", "dexie-export-import": "^4.0.7", "eth-simple-keyring": "^4.2.0", diff --git a/packages/extension-base/src/background/KoniTypes.ts b/packages/extension-base/src/background/KoniTypes.ts index 55e17c5cf93..df04fd9cb7d 100644 --- a/packages/extension-base/src/background/KoniTypes.ts +++ b/packages/extension-base/src/background/KoniTypes.ts @@ -10,6 +10,7 @@ import { RequestOptimalTransferProcess } from '@subwallet/extension-base/service import { CardanoBalanceItem } from '@subwallet/extension-base/services/balance-service/helpers/subscribe/cardano/types'; import { CardanoTransactionConfig } from '@subwallet/extension-base/services/balance-service/transfer/cardano-transfer'; import { TonTransactionConfig } from '@subwallet/extension-base/services/balance-service/transfer/ton-transfer'; +import { BitcoinApiStrategy } from '@subwallet/extension-base/services/chain-service/handler/bitcoin/strategy/types'; import { _CHAIN_VALIDATION_ERROR } from '@subwallet/extension-base/services/chain-service/handler/types'; import { _ChainState, _EvmApi, _NetworkUpsertParams, _SubstrateApi, _ValidateCustomAssetRequest, _ValidateCustomAssetResponse, EnableChainParams, EnableMultiChainParams } from '@subwallet/extension-base/services/chain-service/types'; import { TokenPayFeeInfo } from '@subwallet/extension-base/services/fee-service/interfaces'; @@ -17,10 +18,10 @@ import { _NotificationInfo, NotificationSetup } from '@subwallet/extension-base/ import { AppBannerData, AppConfirmationData, AppPopupData } from '@subwallet/extension-base/services/mkt-campaign-service/types'; import { AuthUrls } from '@subwallet/extension-base/services/request-service/types'; import { CrowdloanContributionsResponse } from '@subwallet/extension-base/services/subscan-service/types'; -import { SWTransactionResponse, SWTransactionResult } from '@subwallet/extension-base/services/transaction-service/types'; +import { BitcoinTransactionData, SWTransactionResponse, SWTransactionResult } from '@subwallet/extension-base/services/transaction-service/types'; import { WalletConnectNotSupportRequest, WalletConnectSessionRequest } from '@subwallet/extension-base/services/wallet-connect-service/types'; import { AccountChainType, AccountJson, AccountsWithCurrentAddress, AddressJson, BalanceJson, BaseRequestSign, BuyServiceInfo, BuyTokenInfo, CommonOptimalTransferPath, CurrentAccountInfo, EarningRewardHistoryItem, EarningRewardJson, EarningStatus, HandleYieldStepParams, InternalRequestSign, LeavePoolAdditionalData, NominationPoolInfo, OptimalYieldPath, OptimalYieldPathParams, RequestAccountBatchExportV2, RequestAccountCreateSuriV2, RequestAccountNameValidate, RequestAccountProxyEdit, RequestAccountProxyForget, RequestBatchJsonGetAccountInfo, RequestBatchRestoreV2, RequestBounceableValidate, RequestChangeAllowOneSign, RequestChangeTonWalletContractVersion, RequestCheckCrossChainTransfer, RequestCheckPublicAndSecretKey, RequestCheckTransfer, RequestCrossChainTransfer, RequestDeriveCreateMultiple, RequestDeriveCreateV3, RequestDeriveValidateV2, RequestEarlyValidateYield, RequestEarningSlippage, RequestExportAccountProxyMnemonic, RequestGetAllTonWalletContractVersion, RequestGetAmountForPair, RequestGetDeriveAccounts, RequestGetDeriveSuggestion, RequestGetTokensCanPayFee, RequestGetYieldPoolTargets, RequestInputAccountSubscribe, RequestJsonGetAccountInfo, RequestJsonRestoreV2, RequestMetadataHash, RequestMnemonicCreateV2, RequestMnemonicValidateV2, RequestPrivateKeyValidateV2, RequestShortenMetadata, RequestStakeCancelWithdrawal, RequestStakeClaimReward, RequestSubmitProcessTransaction, RequestSubscribeProcessById, RequestTransfer, RequestUnlockDotCheckCanMint, RequestUnlockDotSubscribeMintedData, RequestYieldLeave, RequestYieldStepSubmit, RequestYieldWithdrawal, ResponseAccountBatchExportV2, ResponseAccountCreateSuriV2, ResponseAccountNameValidate, ResponseBatchJsonGetAccountInfo, ResponseCheckPublicAndSecretKey, ResponseDeriveValidateV2, ResponseEarlyValidateYield, ResponseExportAccountProxyMnemonic, ResponseGetAllTonWalletContractVersion, ResponseGetDeriveAccounts, ResponseGetDeriveSuggestion, ResponseGetYieldPoolTargets, ResponseInputAccountSubscribe, ResponseJsonGetAccountInfo, ResponseMetadataHash, ResponseMnemonicCreateV2, ResponseMnemonicValidateV2, ResponsePrivateKeyValidateV2, ResponseShortenMetadata, ResponseSubscribeProcessAlive, ResponseSubscribeProcessById, StorageDataInterface, SubmitYieldStepData, SubnetYieldPositionInfo, SwapPair, SwapQuoteResponse, SwapRequest, SwapRequestResult, SwapRequestV2, SwapSubmitParams, SwapTxData, TokenSpendingApprovalParams, UnlockDotTransactionNft, UnstakingStatus, ValidateSwapProcessParams, ValidateYieldProcessParams, YieldPoolInfo, YieldPoolType, YieldPositionInfo } from '@subwallet/extension-base/types'; -import { RequestSubmitTransfer, RequestSubscribeTransfer, ResponseSubscribeTransfer } from '@subwallet/extension-base/types/balance/transfer'; +import { RequestSubmitSignPsbtTransfer, RequestSubmitTransfer, RequestSubmitTransferWithId, RequestSubscribeTransfer, ResponseSubscribeTransfer } from '@subwallet/extension-base/types/balance/transfer'; import { RequestClaimBridge } from '@subwallet/extension-base/types/bridge'; import { GetNotificationParams, RequestIsClaimedPolygonBridge, RequestSwitchStatusParams } from '@subwallet/extension-base/types/notification'; import { InjectedAccount, InjectedAccountWithMeta, MetadataDefBase } from '@subwallet/extension-inject/types'; @@ -28,6 +29,8 @@ import { KeyringPair$Meta } from '@subwallet/keyring/types'; import { KeyringOptions } from '@subwallet/ui-keyring/options/types'; import { KeyringAddress } from '@subwallet/ui-keyring/types'; import { SessionTypes } from '@walletconnect/types/dist/types/sign-client/session'; +import { Psbt } from 'bitcoinjs-lib'; +import BN from 'bn.js'; import { DexieExportJsonStructure } from 'dexie-export-import'; import Web3 from 'web3'; import { RequestArguments, TransactionConfig } from 'web3-core'; @@ -732,7 +735,7 @@ export type TransactionAdditionalInfo = { // ? Pick // : undefined; export interface TransactionHistoryItem { - origin?: 'app' | 'migration' | 'subsquid' | 'subscan', // 'app' or history source + origin?: 'app' | 'migration' | 'subsquid' | 'subscan' | 'blockstream', // 'app' or history source callhash?: string, signature?: string, chain: string, @@ -748,6 +751,7 @@ export interface TransactionHistoryItem{ + txInput: PsbtTransactionArg[]; + txOutput: PsbtTransactionArg[]; + to: string; + value: string; + psbt: Psbt; + tokenSlug: string; +} + +enum SignatureHash { + DEFAULT = 0, + ALL = 1, + NONE = 2, + SINGLE = 3, + ANYONECANPAY = 128 +} + +export interface BitcoinSignPsbtRawRequest { + psbt: string; + allowedSighash?: SignatureHash[]; + signAtIndex?: number | number[]; + broadcast?: boolean; + network: string; + account: string; +} + export interface TonSignRequest { account: AccountJson; hashPayload: string; @@ -1251,6 +1314,21 @@ export interface CardanoTransactionDappConfig { export type ResponseCardanoSignTransaction = Cbor; +export type BitcoinSendTransactionRequest = BitcoinSignRequest + +export interface BitcoinSignatureRequest extends BitcoinSignRequest { + id: string; + payload: unknown; + payloadJson: any; +} + +export interface BitcoinAppState { + networkKey?: string, + isConnected?: boolean, + strategy?: BitcoinApiStrategy, + listenEvents?: string[] +} + // TODO: add account info + dataToSign export type TonSendTransactionRequest = TonTransactionConfig; export type CardanoSendTransactionRequest = CardanoTransactionConfig; @@ -1259,6 +1337,10 @@ export type CardanoSignTransactionRequest = CardanoTransactionDappConfig; export type EvmWatchTransactionRequest = EvmSendTransactionRequest; export type TonWatchTransactionRequest = TonSendTransactionRequest; export type CardanoWatchTransactionRequest = CardanoSendTransactionRequest; +export type BitcoinWatchTransactionRequest = BitcoinSendTransactionRequest; +export type BitcoinSignPsbtRequest = BitcoinSignRequest & { + payload: BitcoinSignPsbtPayload; +}; export interface ConfirmationsQueueItemOptions { requiredPassword?: boolean; @@ -1267,6 +1349,26 @@ export interface ConfirmationsQueueItemOptions { isPassConfirmation?: boolean; } +export interface BitcoinTransactionConfig{ + id?: string, + from?: string | number; + to?: BitcoinRecipientTransactionParams[]; + value?: number | string | BN; + networkKey?: string; + tokenSlug?: string; +} + +export interface SignMessageBitcoinResult { + signature: string; + message: string; + address: string; +} + +export interface SignPsbtBitcoinResult { + psbt: string; + txid?: string +} + export interface ConfirmationsQueueItem extends ConfirmationsQueueItemOptions, ConfirmationRequestBase { payload: T; payloadJson: string; @@ -1333,9 +1435,18 @@ export interface ConfirmationDefinitionsCardano { cardanoWatchTransactionRequest: [ConfirmationsQueueItem, ConfirmationResult] } +export interface ConfirmationDefinitionsBitcoin { + bitcoinSignatureRequest: [ConfirmationsQueueItem, ConfirmationResult], + bitcoinSendTransactionRequest: [ConfirmationsQueueItem, ConfirmationResult], + bitcoinSendTransactionRequestAfterConfirmation: [ConfirmationsQueueItem, ConfirmationResult], + bitcoinWatchTransactionRequest: [ConfirmationsQueueItem, ConfirmationResult], + bitcoinSignPsbtRequest: [ConfirmationsQueueItem, ConfirmationResult], +} + export type ConfirmationType = keyof ConfirmationDefinitions; export type ConfirmationTypeTon = keyof ConfirmationDefinitionsTon; export type ConfirmationTypeCardano = keyof ConfirmationDefinitionsCardano; +export type ConfirmationTypeBitcoin = keyof ConfirmationDefinitionsBitcoin; export type ConfirmationsQueue = { [CT in ConfirmationType]: Record; @@ -1347,14 +1458,24 @@ export type ConfirmationsQueueCardano = { [CT in ConfirmationTypeCardano]: Record; } +export type ConfirmationsQueueBitcoin = { + [CT in ConfirmationTypeBitcoin]: Record; +} + export type RequestConfirmationsSubscribe = null; export type RequestConfirmationsSubscribeTon = null; export type RequestConfirmationsSubscribeCardano = null; +export type RequestConfirmationsSubscribeBitcoin = null; // Design to use only one confirmation export type RequestConfirmationComplete = { [CT in ConfirmationType]?: ConfirmationDefinitions[CT][1]; } + +export type RequestConfirmationCompleteBitcoin = { + [CT in ConfirmationTypeBitcoin]?: ConfirmationDefinitionsBitcoin[CT][1]; +} + export type RequestConfirmationCompleteTon = { [CT in ConfirmationTypeTon]?: ConfirmationDefinitionsTon[CT][1]; } @@ -2132,7 +2253,17 @@ export interface ExtrinsicsDataResponse { /* Core types */ export type _Address = string; -export type _BalanceMetadata = unknown; +export type _BalanceMetadata = BitcoinBalanceMetadata | unknown; +export type BitcoinBalanceMetadata = { + inscriptionCount: number, + runeBalance: string, // sum of BTC in UTXO which contains rune + inscriptionBalance: string // sum of BTC in UTXO which contains rune +} + +export interface AddressBalanceResult { + balance: string; + bitcoinBalanceMetadata: BitcoinBalanceMetadata; +} // Use stringify to communicate, pure boolean value will error with case 'false' value export interface KoniRequestSignatures { @@ -2394,6 +2525,9 @@ export interface KoniRequestSignatures { // Transfer 'pri(accounts.checkTransfer)': [RequestCheckTransfer, ValidateTransactionResponse]; 'pri(accounts.transfer)': [RequestSubmitTransfer, SWTransactionResponse]; + 'pri(accounts.bitcoin.dapp.transfer.confirmation)': [RequestSubmitTransferWithId, SWTransactionResponse]; + 'pri(accounts.psbt.transfer.confirmation)': [RequestSubmitSignPsbtTransfer, SWTransactionResponse]; + 'pri(accounts.getBitcoinTransactionData)': [RequestSubmitTransfer, BitcoinTransactionData]; 'pri(accounts.getOptimalTransferProcess)': [RequestOptimalTransferProcess, CommonOptimalTransferPath]; 'pri(accounts.approveSpending)': [TokenSpendingApprovalParams, SWTransactionResponse]; @@ -2407,9 +2541,11 @@ export interface KoniRequestSignatures { 'pri(confirmations.subscribe)': [RequestConfirmationsSubscribe, ConfirmationsQueue, ConfirmationsQueue]; 'pri(confirmationsTon.subscribe)': [RequestConfirmationsSubscribeTon, ConfirmationsQueueTon, ConfirmationsQueueTon]; 'pri(confirmationsCardano.subscribe)': [RequestConfirmationsSubscribeCardano, ConfirmationsQueueCardano, ConfirmationsQueueCardano]; + 'pri(confirmationsBitcoin.subscribe)': [RequestConfirmationsSubscribeBitcoin, ConfirmationsQueueBitcoin, ConfirmationsQueueBitcoin]; 'pri(confirmations.complete)': [RequestConfirmationComplete, boolean]; 'pri(confirmationsTon.complete)': [RequestConfirmationCompleteTon, boolean]; 'pri(confirmationsCardano.complete)': [RequestConfirmationCompleteCardano, boolean]; + 'pri(confirmationsBitcoin.complete)': [RequestConfirmationCompleteBitcoin, boolean]; 'pub(utils.getRandom)': [RandomTestRequest, number]; 'pub(accounts.listV2)': [RequestAccountList, InjectedAccount[]]; diff --git a/packages/extension-base/src/background/errors/BitcoinProviderError.ts b/packages/extension-base/src/background/errors/BitcoinProviderError.ts new file mode 100644 index 00000000000..e5323926aa8 --- /dev/null +++ b/packages/extension-base/src/background/errors/BitcoinProviderError.ts @@ -0,0 +1,50 @@ +// Copyright 2019-2022 @subwallet/extension-koni authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { SWError } from '@subwallet/extension-base/background/errors/SWError'; +import { BitcoinProviderErrorType } from '@subwallet/extension-base/background/KoniTypes'; +import { detectTranslate } from '@subwallet/extension-base/utils'; +import { t } from 'i18next'; + +const defaultErrorMap: Record = { + USER_REJECTED_REQUEST: { + message: detectTranslate('User Rejected Request'), + code: 4001 + }, + UNAUTHORIZED: { + message: detectTranslate('Failed to sign'), + code: 4100 + }, + UNSUPPORTED_METHOD: { + message: detectTranslate('Unsupported Method'), + code: 4200 + }, + DISCONNECTED: { + message: detectTranslate('Network is disconnected'), + code: 4900 + }, + CHAIN_DISCONNECTED: { + message: detectTranslate('Network is disconnected'), + code: 4901 + }, + INVALID_PARAMS: { + message: detectTranslate('Undefined error. Please contact SubWallet support'), + code: -32602 + }, + INTERNAL_ERROR: { + message: detectTranslate('Undefined error. Please contact SubWallet support'), + code: -32603 + } +}; + +export class BitcoinProviderError extends SWError { + override errorType: BitcoinProviderErrorType; + + constructor (errorType: BitcoinProviderErrorType, errMessage?: string, data?: unknown) { + const { code, message } = defaultErrorMap[errorType]; + const finalMessage = errMessage || t(message || '') || errorType; + + super(errorType, finalMessage, code, data); + this.errorType = errorType; + } +} diff --git a/packages/extension-base/src/constants/bitcoin.ts b/packages/extension-base/src/constants/bitcoin.ts new file mode 100644 index 00000000000..39771e5a610 --- /dev/null +++ b/packages/extension-base/src/constants/bitcoin.ts @@ -0,0 +1,15 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +// https://bitcoin.stackexchange.com/a/41082/139277 +import { BitcoinAddressType } from '@subwallet/keyring/types'; + +export const BTC_DUST_AMOUNT: Record = { + [BitcoinAddressType.p2pkh]: 546, + [BitcoinAddressType.p2sh]: 540, + [BitcoinAddressType.p2tr]: 330, + [BitcoinAddressType.p2wpkh]: 294, + [BitcoinAddressType.p2wsh]: 330 +}; + +export const BITCOIN_DECIMAL = 8; diff --git a/packages/extension-base/src/constants/index.ts b/packages/extension-base/src/constants/index.ts index ff8532194d9..65f80a3dcf1 100644 --- a/packages/extension-base/src/constants/index.ts +++ b/packages/extension-base/src/constants/index.ts @@ -10,6 +10,7 @@ export const CRON_AUTO_RECOVER_DOTSAMA_INTERVAL = 60000; export const CRON_AUTO_RECOVER_WEB3_INTERVAL = 90000; export const ACALA_REFRESH_CROWDLOAN_INTERVAL = 300000; export const ASTAR_REFRESH_BALANCE_INTERVAL = 60000; +export const BITCOIN_REFRESH_BALANCE_INTERVAL = 600000; export const SUB_TOKEN_REFRESH_BALANCE_INTERVAL = 60000; export const CRON_REFRESH_NFT_INTERVAL = 7200000; export const CRON_REFRESH_MKT_CAMPAIGN_INTERVAL = 15 * BASE_MINUTE_INTERVAL; @@ -73,3 +74,4 @@ export * from './signing'; export * from './staking'; export * from './storage'; export * from './remind-notification-time'; +export * from './bitcoin'; diff --git a/packages/extension-base/src/core/logic-validation/recipientAddress.ts b/packages/extension-base/src/core/logic-validation/recipientAddress.ts index d4b04b1e034..a6678004982 100644 --- a/packages/extension-base/src/core/logic-validation/recipientAddress.ts +++ b/packages/extension-base/src/core/logic-validation/recipientAddress.ts @@ -2,10 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { ActionType, ValidateRecipientParams, ValidationCondition } from '@subwallet/extension-base/core/types'; -import { _isAddress, _isNotDuplicateAddress, _isNotNull, _isSupportLedgerAccount, _isValidAddressForEcosystem, _isValidCardanoAddressFormat, _isValidSubstrateAddressFormat, _isValidTonAddressFormat } from '@subwallet/extension-base/core/utils'; +import { _isAddress, _isNotDuplicateAddress, _isNotNull, _isSupportLedgerAccount, _isValidAddressForEcosystem, _isValidBitcoinAddressFormat, _isValidCardanoAddressFormat, _isValidSubstrateAddressFormat, _isValidTonAddressFormat } from '@subwallet/extension-base/core/utils'; import { AccountSignMode } from '@subwallet/extension-base/types'; import { detectTranslate } from '@subwallet/extension-base/utils'; import { isCardanoAddress, isSubstrateAddress, isTonAddress } from '@subwallet/keyring'; +import { isBitcoinAddress } from '@subwallet/keyring/utils/address/validate'; function getConditions (validateRecipientParams: ValidateRecipientParams): ValidationCondition[] { const { account, actionType, autoFormatValue, destChainInfo, srcChain, toAddress } = validateRecipientParams; @@ -29,6 +30,10 @@ function getConditions (validateRecipientParams: ValidateRecipientParams): Valid conditions.push(ValidationCondition.IS_VALID_CARDANO_ADDRESS_FORMAT); } + if (isBitcoinAddress(toAddress)) { + conditions.push(ValidationCondition.IS_VALID_BITCOIN_ADDRESS_FORMAT); + } + if (srcChain === destChainInfo.slug && isSendAction && !destChainInfo.tonInfo && !destChainInfo.cardanoInfo) { conditions.push(ValidationCondition.IS_NOT_DUPLICATE_ADDRESS); } @@ -85,6 +90,12 @@ function getValidationFunctions (conditions: ValidationCondition[]): Array<(vali break; } + case ValidationCondition.IS_VALID_BITCOIN_ADDRESS_FORMAT: { + validationFunctions.push(_isValidBitcoinAddressFormat); + + break; + } + case ValidationCondition.IS_NOT_DUPLICATE_ADDRESS: { validationFunctions.push(_isNotDuplicateAddress); diff --git a/packages/extension-base/src/core/logic-validation/transfer.ts b/packages/extension-base/src/core/logic-validation/transfer.ts index 04231e65f36..ef381a81aeb 100644 --- a/packages/extension-base/src/core/logic-validation/transfer.ts +++ b/packages/extension-base/src/core/logic-validation/transfer.ts @@ -14,10 +14,10 @@ import { _TRANSFER_CHAIN_GROUP } from '@subwallet/extension-base/services/chain- import { _EvmApi, _SubstrateApi, _TonApi } from '@subwallet/extension-base/services/chain-service/types'; import { _getAssetDecimals, _getAssetPriceId, _getAssetSymbol, _getChainNativeTokenBasicInfo, _getContractAddressOfToken, _getTokenMinAmount, _isCIP26Token, _isNativeToken, _isNativeTokenBySlug, _isTokenEvmSmartContract, _isTokenTonSmartContract } from '@subwallet/extension-base/services/chain-service/utils'; import { calculateToAmountByReservePool, FEE_COVERAGE_PERCENTAGE_SPECIAL_CASE } from '@subwallet/extension-base/services/fee-service/utils'; -import { isCardanoTransaction, isSubstrateTransaction, isTonTransaction } from '@subwallet/extension-base/services/transaction-service/helpers'; +import { isBitcoinTransaction, isCardanoTransaction, isSubstrateTransaction, isTonTransaction } from '@subwallet/extension-base/services/transaction-service/helpers'; import { OptionalSWTransaction, SWTransactionInput, SWTransactionResponse } from '@subwallet/extension-base/services/transaction-service/types'; -import { AccountSignMode, BasicTxErrorType, BasicTxWarningCode, EvmEIP1559FeeOption, EvmFeeInfo, TransferTxErrorType } from '@subwallet/extension-base/types'; -import { balanceFormatter, combineEthFee, formatNumber, pairToAccount } from '@subwallet/extension-base/utils'; +import { AccountSignMode, BasicTxErrorType, BasicTxWarningCode, BitcoinFeeInfo, BitcoinFeeRate, EvmEIP1559FeeOption, EvmFeeInfo, FeeInfo, TransferTxErrorType } from '@subwallet/extension-base/types'; +import { balanceFormatter, combineBitcoinFee, combineEthFee, formatNumber, getSizeInfo, pairToAccount } from '@subwallet/extension-base/utils'; import { isCardanoAddress, isTonAddress } from '@subwallet/keyring'; import { KeyringPair } from '@subwallet/keyring/types'; import { keyring } from '@subwallet/ui-keyring'; @@ -347,7 +347,7 @@ export function checkSupportForTransaction (validationResponse: SWTransactionRes } } -export async function estimateFeeForTransaction (validationResponse: SWTransactionResponse, transaction: OptionalSWTransaction, chainInfo: _ChainInfo, evmApi: _EvmApi, substrateApi: _SubstrateApi, priceMap: Record, feeInfo: EvmFeeInfo, nativeTokenInfo: _ChainAsset, nonNativeTokenPayFeeInfo: _ChainAsset | undefined, isTransferLocalTokenAndPayThatTokenAsFee: boolean | undefined): Promise { +export async function estimateFeeForTransaction (validationResponse: SWTransactionResponse, transaction: OptionalSWTransaction, chainInfo: _ChainInfo, evmApi: _EvmApi, substrateApi: _SubstrateApi, priceMap: Record, feeInfo: FeeInfo, nativeTokenInfo: _ChainAsset, nonNativeTokenPayFeeInfo: _ChainAsset | undefined, isTransferLocalTokenAndPayThatTokenAsFee: boolean | undefined): Promise { const estimateFee: FeeData = { symbol: '', decimals: 0, @@ -358,6 +358,7 @@ export async function estimateFeeForTransaction (validationResponse: SWTransacti estimateFee.decimals = decimals; estimateFee.symbol = symbol; + const { address, feeCustom, feeOption } = validationResponse; if (transaction) { try { @@ -367,15 +368,33 @@ export async function estimateFeeForTransaction (validationResponse: SWTransacti estimateFee.value = transaction.estimateFee; // todo: might need to update logic estimate fee inside for future actions excluding normal transfer Ton and Jetton } else if (isCardanoTransaction(transaction)) { estimateFee.value = transaction.estimateCardanoFee; + } else if (isBitcoinTransaction(transaction)) { + const feeCombine = combineBitcoinFee(feeInfo as BitcoinFeeInfo, feeOption, feeCustom as BitcoinFeeRate); + + const recipients: string[] = []; + + for (const txOutput of transaction.txOutputs) { + txOutput.address && recipients.push(txOutput.address); + } + + // TODO: Need review + const sizeInfo = getSizeInfo({ + inputLength: transaction.inputCount, + recipients: recipients, + sender: address + }); + + estimateFee.value = Math.ceil(feeCombine.feeRate * sizeInfo.txVBytes).toString(); } else { - const gasLimit = transaction.gas || await evmApi.api.eth.estimateGas(transaction); + const _transaction = transaction; + const gasLimit = _transaction.gas || await evmApi.api.eth.estimateGas(_transaction); - const feeCombine = combineEthFee(feeInfo, validationResponse.feeOption, validationResponse.feeCustom as EvmEIP1559FeeOption); + const feeCombine = combineEthFee(feeInfo as EvmFeeInfo, validationResponse.feeOption, validationResponse.feeCustom as EvmEIP1559FeeOption); - if (transaction.maxFeePerGas) { - estimateFee.value = new BigN(transaction.maxFeePerGas.toString()).multipliedBy(gasLimit).toFixed(0); - } else if (transaction.gasPrice) { - estimateFee.value = new BigN(transaction.gasPrice.toString()).multipliedBy(gasLimit).toFixed(0); + if (_transaction.maxFeePerGas) { + estimateFee.value = new BigN(_transaction.maxFeePerGas.toString()).multipliedBy(gasLimit).toFixed(0); + } else if (_transaction.gasPrice) { + estimateFee.value = new BigN(_transaction.gasPrice.toString()).multipliedBy(gasLimit).toFixed(0); } else { if (feeCombine.maxFeePerGas) { const maxFee = new BigN(feeCombine.maxFeePerGas); // TODO: Need review diff --git a/packages/extension-base/src/core/types.ts b/packages/extension-base/src/core/types.ts index 41d9a680f31..05e516798fe 100644 --- a/packages/extension-base/src/core/types.ts +++ b/packages/extension-base/src/core/types.ts @@ -13,6 +13,7 @@ export enum ValidationCondition { IS_VALID_SUBSTRATE_ADDRESS_FORMAT = 'IS_VALID_SUBSTRATE_ADDRESS_FORMAT', IS_VALID_TON_ADDRESS_FORMAT = 'IS_VALID_TON_ADDRESS_FORMAT', IS_VALID_CARDANO_ADDRESS_FORMAT = 'IS_VALID_CARDANO_ADDRESS_FORMAT', + IS_VALID_BITCOIN_ADDRESS_FORMAT = 'IS_VALID_BITCOIN_ADDRESS_FORMAT', IS_NOT_DUPLICATE_ADDRESS = 'IS_NOT_DUPLICATE_ADDRESS', IS_SUPPORT_LEDGER_ACCOUNT = 'IS_SUPPORT_LEDGER_ACCOUNT' } diff --git a/packages/extension-base/src/core/utils.ts b/packages/extension-base/src/core/utils.ts index 4f8eab617b5..b56fafd03a6 100644 --- a/packages/extension-base/src/core/utils.ts +++ b/packages/extension-base/src/core/utils.ts @@ -7,10 +7,11 @@ import { BalanceAccountType } from '@subwallet/extension-base/core/substrate/typ import { LedgerMustCheckType, ValidateRecipientParams } from '@subwallet/extension-base/core/types'; import { tonAddressInfo } from '@subwallet/extension-base/services/balance-service/helpers/subscribe/ton/utils'; import { _SubstrateAdapterQueryArgs, _SubstrateApi } from '@subwallet/extension-base/services/chain-service/types'; -import { _getTokenOnChainAssetId, _getXcmAssetMultilocation, _isBridgedToken, _isChainCardanoCompatible, _isChainEvmCompatible, _isChainSubstrateCompatible, _isChainTonCompatible } from '@subwallet/extension-base/services/chain-service/utils'; +import { _getTokenOnChainAssetId, _getXcmAssetMultilocation, _isBridgedToken, _isChainBitcoinCompatible, _isChainCardanoCompatible, _isChainEvmCompatible, _isChainSubstrateCompatible, _isChainTonCompatible } from '@subwallet/extension-base/services/chain-service/utils'; import { AccountJson } from '@subwallet/extension-base/types'; import { isAddressAndChainCompatible, isSameAddress, reformatAddress } from '@subwallet/extension-base/utils'; import { isAddress, isCardanoTestnetAddress, isTonAddress } from '@subwallet/keyring'; +import { getBitcoinAddressInfo, validateBitcoinAddress } from '@subwallet/keyring/utils'; import { AnyJson } from '@polkadot/types/types'; import { isEthereumAddress } from '@polkadot/util-crypto'; @@ -68,7 +69,8 @@ export function _isValidAddressForEcosystem (validateRecipientParams: ValidateRe if (_isChainEvmCompatible(destChainInfo) || _isChainSubstrateCompatible(destChainInfo) || _isChainTonCompatible(destChainInfo) || - _isChainCardanoCompatible(destChainInfo)) { + _isChainCardanoCompatible(destChainInfo) || + _isChainBitcoinCompatible(destChainInfo)) { return 'Recipient address must be the same type as sender address'; } @@ -112,6 +114,17 @@ export function _isValidCardanoAddressFormat (validateRecipientParams: ValidateR return ''; } +export function _isValidBitcoinAddressFormat (validateRecipientParams: ValidateRecipientParams): string { + const { destChainInfo, toAddress } = validateRecipientParams; + const addressInfo = validateBitcoinAddress(toAddress) ? getBitcoinAddressInfo(toAddress) : null; + + if (addressInfo?.network !== destChainInfo.bitcoinInfo?.bitcoinNetwork) { + return `Recipient address must be a valid ${destChainInfo.name} address`; + } + + return ''; +} + export function _isNotDuplicateAddress (validateRecipientParams: ValidateRecipientParams): string { const { fromAddress, toAddress } = validateRecipientParams; diff --git a/packages/extension-base/src/koni/background/handlers/Extension.ts b/packages/extension-base/src/koni/background/handlers/Extension.ts index 10fe2f9744c..36f3c2049b6 100644 --- a/packages/extension-base/src/koni/background/handlers/Extension.ts +++ b/packages/extension-base/src/koni/background/handlers/Extension.ts @@ -7,7 +7,7 @@ import { _AssetRef, _AssetType, _ChainAsset, _ChainInfo, _MultiChainAsset } from import { TransactionError } from '@subwallet/extension-base/background/errors/TransactionError'; import { withErrorLog } from '@subwallet/extension-base/background/handlers/helpers'; import { createSubscription } from '@subwallet/extension-base/background/handlers/subscriptions'; -import { AccountExternalError, AddressBookInfo, AmountData, AmountDataWithId, AssetSetting, AssetSettingUpdateReq, BondingOptionParams, BrowserConfirmationType, CampaignBanner, CampaignData, CampaignDataType, ChainType, CronReloadRequest, CrowdloanJson, ExternalRequestPromiseStatus, ExtrinsicType, HistoryTokenPriceJSON, KeyringState, MantaPayEnableMessage, MantaPayEnableParams, MantaPayEnableResponse, MantaPaySyncState, NftCollection, NftJson, NftTransactionRequest, NftTransactionResponse, PriceJson, RequestAccountCreateExternalV2, RequestAccountCreateHardwareMultiple, RequestAccountCreateHardwareV2, RequestAccountCreateWithSecretKey, RequestAccountExportPrivateKey, RequestAddInjectedAccounts, RequestApproveConnectWalletSession, RequestApproveWalletConnectNotSupport, RequestAuthorization, RequestAuthorizationBlock, RequestAuthorizationPerAccount, RequestAuthorizationPerSite, RequestAuthorizeApproveV2, RequestBondingSubmit, RequestCameraSettings, RequestCampaignBannerComplete, RequestChangeEnableChainPatrol, RequestChangeLanguage, RequestChangeMasterPassword, RequestChangePriceCurrency, RequestChangeShowBalance, RequestChangeShowZeroBalance, RequestChangeTimeAutoLock, RequestConfirmationComplete, RequestConfirmationCompleteCardano, RequestConfirmationCompleteTon, RequestConnectWalletConnect, RequestCrowdloanContributions, RequestDeleteContactAccount, RequestDisconnectWalletConnectSession, RequestEditContactAccount, RequestFindRawMetadata, RequestForgetSite, RequestFreeBalance, RequestGetHistoryTokenPriceData, RequestGetTransaction, RequestKeyringExportMnemonic, RequestMigratePassword, RequestMigrateSoloAccount, RequestMigrateUnifiedAndFetchEligibleSoloAccounts, RequestParseEvmContractInput, RequestParseTransactionSubstrate, RequestPassPhishingPage, RequestPingSession, RequestQrParseRLP, RequestQrSignEvm, RequestQrSignSubstrate, RequestRejectConnectWalletSession, RequestRejectExternalRequest, RequestRejectWalletConnectNotSupport, RequestRemoveInjectedAccounts, RequestResetWallet, RequestResolveExternalRequest, RequestSaveAppConfig, RequestSaveBrowserConfig, RequestSaveMigrationAcknowledgedStatus, RequestSaveOSConfig, RequestSaveRecentAccount, RequestSaveUnifiedAccountMigrationInProgress, RequestSettingsType, RequestSigningApprovePasswordV2, RequestStakePoolingBonding, RequestStakePoolingUnbonding, RequestSubscribeHistory, RequestSubstrateNftSubmitTransaction, RequestSwitchCurrentNetworkAuthorization, RequestTuringCancelStakeCompound, RequestTuringStakeCompound, RequestUnbondingSubmit, RequestUnlockKeyring, RequestUnlockType, ResolveAddressToDomainRequest, ResolveDomainRequest, ResponseAccountCreateWithSecretKey, ResponseAccountExportPrivateKey, ResponseChangeMasterPassword, ResponseFindRawMetadata, ResponseKeyringExportMnemonic, ResponseMigratePassword, ResponseMigrateSoloAccount, ResponseMigrateUnifiedAndFetchEligibleSoloAccounts, ResponseNftImport, ResponseParseEvmContractInput, ResponseParseTransactionSubstrate, ResponseQrParseRLP, ResponseQrSignEvm, ResponseQrSignSubstrate, ResponseRejectExternalRequest, ResponseResetWallet, ResponseResolveExternalRequest, ResponseSubscribeCurrentTokenPrice, ResponseSubscribeHistory, ResponseUnlockKeyring, ShowCampaignPopupRequest, StakingJson, StakingRewardJson, StakingType, ThemeNames, TokenPriorityDetails, TransactionHistoryItem, TransactionResponse, UiSettings, ValidateNetworkRequest, ValidateNetworkResponse, ValidatorInfo } from '@subwallet/extension-base/background/KoniTypes'; +import { AccountExternalError, AddressBookInfo, AmountData, AmountDataWithId, AssetSetting, AssetSettingUpdateReq, BondingOptionParams, BrowserConfirmationType, CampaignBanner, CampaignData, CampaignDataType, ChainType, CronReloadRequest, CrowdloanJson, ExternalRequestPromiseStatus, ExtrinsicType, HistoryTokenPriceJSON, KeyringState, MantaPayEnableMessage, MantaPayEnableParams, MantaPayEnableResponse, MantaPaySyncState, NftCollection, NftJson, NftTransactionRequest, NftTransactionResponse, PriceJson, RequestAccountCreateExternalV2, RequestAccountCreateHardwareMultiple, RequestAccountCreateHardwareV2, RequestAccountCreateWithSecretKey, RequestAccountExportPrivateKey, RequestAddInjectedAccounts, RequestApproveConnectWalletSession, RequestApproveWalletConnectNotSupport, RequestAuthorization, RequestAuthorizationBlock, RequestAuthorizationPerAccount, RequestAuthorizationPerSite, RequestAuthorizeApproveV2, RequestBondingSubmit, RequestCameraSettings, RequestCampaignBannerComplete, RequestChangeEnableChainPatrol, RequestChangeLanguage, RequestChangeMasterPassword, RequestChangePriceCurrency, RequestChangeShowBalance, RequestChangeShowZeroBalance, RequestChangeTimeAutoLock, RequestConfirmationComplete, RequestConfirmationCompleteBitcoin, RequestConfirmationCompleteCardano, RequestConfirmationCompleteTon, RequestConnectWalletConnect, RequestCrowdloanContributions, RequestDeleteContactAccount, RequestDisconnectWalletConnectSession, RequestEditContactAccount, RequestFindRawMetadata, RequestForgetSite, RequestFreeBalance, RequestGetHistoryTokenPriceData, RequestGetTransaction, RequestKeyringExportMnemonic, RequestMigratePassword, RequestMigrateSoloAccount, RequestMigrateUnifiedAndFetchEligibleSoloAccounts, RequestParseEvmContractInput, RequestParseTransactionSubstrate, RequestPassPhishingPage, RequestPingSession, RequestQrParseRLP, RequestQrSignEvm, RequestQrSignSubstrate, RequestRejectConnectWalletSession, RequestRejectExternalRequest, RequestRejectWalletConnectNotSupport, RequestRemoveInjectedAccounts, RequestResetWallet, RequestResolveExternalRequest, RequestSaveAppConfig, RequestSaveBrowserConfig, RequestSaveMigrationAcknowledgedStatus, RequestSaveOSConfig, RequestSaveRecentAccount, RequestSaveUnifiedAccountMigrationInProgress, RequestSettingsType, RequestSigningApprovePasswordV2, RequestStakePoolingBonding, RequestStakePoolingUnbonding, RequestSubscribeHistory, RequestSubstrateNftSubmitTransaction, RequestSwitchCurrentNetworkAuthorization, RequestTuringCancelStakeCompound, RequestTuringStakeCompound, RequestUnbondingSubmit, RequestUnlockKeyring, RequestUnlockType, ResolveAddressToDomainRequest, ResolveDomainRequest, ResponseAccountCreateWithSecretKey, ResponseAccountExportPrivateKey, ResponseChangeMasterPassword, ResponseFindRawMetadata, ResponseKeyringExportMnemonic, ResponseMigratePassword, ResponseMigrateSoloAccount, ResponseMigrateUnifiedAndFetchEligibleSoloAccounts, ResponseNftImport, ResponseParseEvmContractInput, ResponseParseTransactionSubstrate, ResponseQrParseRLP, ResponseQrSignEvm, ResponseQrSignSubstrate, ResponseRejectExternalRequest, ResponseResetWallet, ResponseResolveExternalRequest, ResponseSubscribeCurrentTokenPrice, ResponseSubscribeHistory, ResponseUnlockKeyring, ShowCampaignPopupRequest, StakingJson, StakingRewardJson, StakingType, ThemeNames, TokenPriorityDetails, TransactionHistoryItem, TransactionResponse, UiSettings, ValidateNetworkRequest, ValidateNetworkResponse, ValidatorInfo } from '@subwallet/extension-base/background/KoniTypes'; import { AccountAuthType, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountExport, RequestAuthorizeCancel, RequestAuthorizeReject, RequestCurrentAccountAddress, RequestMetadataApprove, RequestMetadataReject, RequestSigningApproveSignature, RequestSigningCancel, RequestTypes, ResponseAccountExport, ResponseAuthorizeList, ResponseType, SigningRequest, WindowOpenParams } from '@subwallet/extension-base/background/types'; import { TransactionWarning } from '@subwallet/extension-base/background/warnings/TransactionWarning'; import { _SUPPORT_TOKEN_PAY_FEE_GROUP, ALL_ACCOUNT_KEY, LATEST_SESSION } from '@subwallet/extension-base/constants'; @@ -30,6 +30,7 @@ import KoniState from '@subwallet/extension-base/koni/background/handlers/State' import { RequestOptimalTransferProcess } from '@subwallet/extension-base/services/balance-service/helpers/process'; import { DEFAULT_CARDANO_TTL_OFFSET } from '@subwallet/extension-base/services/balance-service/helpers/subscribe/cardano/consts'; import { isBounceableAddress } from '@subwallet/extension-base/services/balance-service/helpers/subscribe/ton/utils'; +import { createBitcoinTransaction } from '@subwallet/extension-base/services/balance-service/transfer/bitcoin-transfer'; import { createCardanoTransaction } from '@subwallet/extension-base/services/balance-service/transfer/cardano-transfer'; import { getERC20TransactionObject, getERC721Transaction, getEVMTransactionObject, getPSP34TransferExtrinsic } from '@subwallet/extension-base/services/balance-service/transfer/smart-contract'; import { createSubstrateExtrinsic } from '@subwallet/extension-base/services/balance-service/transfer/token'; @@ -42,7 +43,7 @@ import { _isPosChainBridge, getClaimPosBridge } from '@subwallet/extension-base/ import { estimateXcmFee } from '@subwallet/extension-base/services/balance-service/transfer/xcm/utils'; import { _DEFAULT_MANTA_ZK_CHAIN, _MANTA_ZK_CHAIN_GROUP, _ZK_ASSET_PREFIX } from '@subwallet/extension-base/services/chain-service/constants'; import { _ChainApiStatus, _ChainConnectionStatus, _ChainState, _NetworkUpsertParams, _ValidateCustomAssetRequest, _ValidateCustomAssetResponse, EnableChainParams, EnableMultiChainParams } from '@subwallet/extension-base/services/chain-service/types'; -import { _getAssetDecimals, _getAssetSymbol, _getChainNativeTokenBasicInfo, _getContractAddressOfToken, _getEvmChainId, _isAssetSmartContractNft, _isChainEnabled, _isChainEvmCompatible, _isChainSubstrateCompatible, _isCustomAsset, _isLocalToken, _isMantaZkAsset, _isNativeToken, _isNativeTokenBySlug, _isPureEvmChain, _isTokenEvmSmartContract, _isTokenTransferredByCardano, _isTokenTransferredByEvm, _isTokenTransferredByTon } from '@subwallet/extension-base/services/chain-service/utils'; +import { _getAssetDecimals, _getAssetSymbol, _getChainNativeTokenBasicInfo, _getContractAddressOfToken, _getEvmChainId, _isAssetSmartContractNft, _isChainEnabled, _isChainEvmCompatible, _isChainSubstrateCompatible, _isCustomAsset, _isLocalToken, _isMantaZkAsset, _isNativeToken, _isNativeTokenBySlug, _isPureEvmChain, _isTokenEvmSmartContract, _isTokenTransferredByBitcoin, _isTokenTransferredByCardano, _isTokenTransferredByEvm, _isTokenTransferredByTon } from '@subwallet/extension-base/services/chain-service/utils'; import { TokenHasBalanceInfo, TokenPayFeeInfo } from '@subwallet/extension-base/services/fee-service/interfaces'; import { calculateToAmountByReservePool } from '@subwallet/extension-base/services/fee-service/utils'; import { batchExtrinsicSetFeeHydration, getAssetHubTokensCanPayFee, getHydrationTokensCanPayFee } from '@subwallet/extension-base/services/fee-service/utils/tokenPayFee'; @@ -56,7 +57,7 @@ import { isProposalExpired, isSupportWalletConnectChain, isSupportWalletConnectN import { ResultApproveWalletConnectSession, WalletConnectNotSupportRequest, WalletConnectSessionRequest } from '@subwallet/extension-base/services/wallet-connect-service/types'; import { SWStorage } from '@subwallet/extension-base/storage'; import { AccountsStore } from '@subwallet/extension-base/stores'; -import { AccountJson, AccountProxyMap, AccountSignMode, AccountsWithCurrentAddress, BalanceJson, BasicTxErrorType, BasicTxWarningCode, BriefProcessStep, BuyServiceInfo, BuyTokenInfo, CommonOptimalTransferPath, CommonStepFeeInfo, CommonStepType, EarningProcessType, EarningRewardJson, EvmFeeInfo, FeeChainType, FeeInfo, HandleYieldStepData, NominationPoolInfo, OptimalYieldPathParams, ProcessStep, ProcessTransactionData, ProcessType, RequestAccountBatchExportV2, RequestAccountCreateSuriV2, RequestAccountNameValidate, RequestBatchJsonGetAccountInfo, RequestBatchRestoreV2, RequestBounceableValidate, RequestChangeAllowOneSign, RequestChangeTonWalletContractVersion, RequestCheckPublicAndSecretKey, RequestClaimBridge, RequestCrossChainTransfer, RequestDeriveCreateMultiple, RequestDeriveCreateV3, RequestDeriveValidateV2, RequestEarlyValidateYield, RequestEarningSlippage, RequestExportAccountProxyMnemonic, RequestGetAllTonWalletContractVersion, RequestGetAmountForPair, RequestGetDeriveAccounts, RequestGetDeriveSuggestion, RequestGetTokensCanPayFee, RequestGetYieldPoolTargets, RequestInputAccountSubscribe, RequestJsonGetAccountInfo, RequestJsonRestoreV2, RequestMetadataHash, RequestMnemonicCreateV2, RequestMnemonicValidateV2, RequestPrivateKeyValidateV2, RequestShortenMetadata, RequestStakeCancelWithdrawal, RequestStakeClaimReward, RequestSubmitProcessTransaction, RequestSubscribeProcessById, RequestUnlockDotCheckCanMint, RequestUnlockDotSubscribeMintedData, RequestYieldLeave, RequestYieldStepSubmit, RequestYieldWithdrawal, ResponseAccountBatchExportV2, ResponseAccountCreateSuriV2, ResponseAccountNameValidate, ResponseBatchJsonGetAccountInfo, ResponseCheckPublicAndSecretKey, ResponseDeriveValidateV2, ResponseExportAccountProxyMnemonic, ResponseGetAllTonWalletContractVersion, ResponseGetDeriveAccounts, ResponseGetDeriveSuggestion, ResponseGetYieldPoolTargets, ResponseInputAccountSubscribe, ResponseJsonGetAccountInfo, ResponseMetadataHash, ResponseMnemonicCreateV2, ResponseMnemonicValidateV2, ResponsePrivateKeyValidateV2, ResponseShortenMetadata, ResponseSubscribeProcessAlive, ResponseSubscribeProcessById, StakingTxErrorType, StepStatus, StorageDataInterface, SummaryEarningProcessData, SwapBaseTxData, SwapFeeType, SwapRequestV2, TokenSpendingApprovalParams, ValidateYieldProcessParams, YieldPoolType, YieldStepType, YieldTokenBaseInfo } from '@subwallet/extension-base/types'; +import { AccountJson, AccountProxyMap, AccountSignMode, AccountsWithCurrentAddress, BalanceJson, BasicTxErrorType, BasicTxWarningCode, BriefProcessStep, BuyServiceInfo, BuyTokenInfo, CommonOptimalTransferPath, CommonStepFeeInfo, CommonStepType, EarningProcessType, EarningRewardJson, EvmFeeInfo, FeeChainType, FeeCustom, FeeInfo, HandleYieldStepData, NominationPoolInfo, OptimalYieldPathParams, ProcessStep, ProcessTransactionData, ProcessType, RequestAccountBatchExportV2, RequestAccountCreateSuriV2, RequestAccountNameValidate, RequestBatchJsonGetAccountInfo, RequestBatchRestoreV2, RequestBounceableValidate, RequestChangeAllowOneSign, RequestChangeTonWalletContractVersion, RequestCheckPublicAndSecretKey, RequestClaimBridge, RequestCrossChainTransfer, RequestDeriveCreateMultiple, RequestDeriveCreateV3, RequestDeriveValidateV2, RequestEarlyValidateYield, RequestEarningSlippage, RequestExportAccountProxyMnemonic, RequestGetAllTonWalletContractVersion, RequestGetAmountForPair, RequestGetDeriveAccounts, RequestGetDeriveSuggestion, RequestGetTokensCanPayFee, RequestGetYieldPoolTargets, RequestInputAccountSubscribe, RequestJsonGetAccountInfo, RequestJsonRestoreV2, RequestMetadataHash, RequestMnemonicCreateV2, RequestMnemonicValidateV2, RequestPrivateKeyValidateV2, RequestShortenMetadata, RequestStakeCancelWithdrawal, RequestStakeClaimReward, RequestSubmitProcessTransaction, RequestSubscribeProcessById, RequestUnlockDotCheckCanMint, RequestUnlockDotSubscribeMintedData, RequestYieldLeave, RequestYieldStepSubmit, RequestYieldWithdrawal, ResponseAccountBatchExportV2, ResponseAccountCreateSuriV2, ResponseAccountNameValidate, ResponseBatchJsonGetAccountInfo, ResponseCheckPublicAndSecretKey, ResponseDeriveValidateV2, ResponseExportAccountProxyMnemonic, ResponseGetAllTonWalletContractVersion, ResponseGetDeriveAccounts, ResponseGetDeriveSuggestion, ResponseGetYieldPoolTargets, ResponseInputAccountSubscribe, ResponseJsonGetAccountInfo, ResponseMetadataHash, ResponseMnemonicCreateV2, ResponseMnemonicValidateV2, ResponsePrivateKeyValidateV2, ResponseShortenMetadata, ResponseSubscribeProcessAlive, ResponseSubscribeProcessById, StakingTxErrorType, StepStatus, StorageDataInterface, SummaryEarningProcessData, SwapBaseTxData, SwapFeeType, SwapRequestV2, TokenSpendingApprovalParams, ValidateYieldProcessParams, YieldPoolType, YieldStepType, YieldTokenBaseInfo } from '@subwallet/extension-base/types'; import { RequestAccountProxyEdit, RequestAccountProxyForget } from '@subwallet/extension-base/types/account/action/edit'; import { RequestSubmitTransfer, RequestSubscribeTransfer, ResponseSubscribeTransfer } from '@subwallet/extension-base/types/balance/transfer'; import { GetNotificationParams, RequestIsClaimedPolygonBridge, RequestSwitchStatusParams } from '@subwallet/extension-base/types/notification'; @@ -67,6 +68,7 @@ import { getId } from '@subwallet/extension-base/utils/getId'; import { MetadataDef } from '@subwallet/extension-inject/types'; import { getKeypairTypeByAddress, isAddress, isCardanoAddress, isSubstrateAddress, isTonAddress } from '@subwallet/keyring'; import { CardanoKeypairTypes, EthereumKeypairTypes, SubstrateKeypairTypes, TonKeypairTypes } from '@subwallet/keyring/types'; +import { isBitcoinAddress } from '@subwallet/keyring/utils/address/validate'; import { keyring } from '@subwallet/ui-keyring'; import { SubjectInfo } from '@subwallet/ui-keyring/observable/types'; import { KeyringAddress, KeyringJson$Meta } from '@subwallet/ui-keyring/types'; @@ -74,6 +76,7 @@ import { ProposalTypes } from '@walletconnect/types/dist/types/sign-client/propo import { SessionTypes } from '@walletconnect/types/dist/types/sign-client/session'; import { getSdkError } from '@walletconnect/utils'; import BigN from 'bignumber.js'; +import * as bitcoin from 'bitcoinjs-lib'; import { t } from 'i18next'; import { combineLatest, Subject } from 'rxjs'; import { TransactionConfig } from 'web3-core'; @@ -1423,6 +1426,7 @@ export default class KoniExtension { const transferAmount: AmountData = { value: '0', symbol: _getAssetSymbol(transferTokenInfo), decimals: _getAssetDecimals(transferTokenInfo) }; let transaction: SWTransaction['transaction'] | null | undefined; + let overrideFeeCustom: FeeCustom | undefined; const transferTokenAvailable = await this.getAddressTransferableBalance({ address: from, networkKey: chain, token: tokenSlug, extrinsicType }); @@ -1498,6 +1502,38 @@ export default class KoniExtension { cardanoApi, nativeTokenInfo }); + } else if (isBitcoinAddress(from) && isBitcoinAddress(to) && _isTokenTransferredByBitcoin(transferTokenInfo)) { + // Note: Currently supports transferring only the native token, Bitcoin. + chainType = ChainType.BITCOIN; + const chainInfo = this.#koniState.getChainInfo(chain); + + const network = chainInfo.isTestnet ? bitcoin.networks.testnet : bitcoin.networks.bitcoin; + const txVal: string = transferAll ? transferTokenAvailable.value : (value || '0'); + const bitcoinApi = this.#koniState.getBitcoinApi(chain); + const feeInfo = await this.#koniState.feeService.subscribeChainFee(getId(), chain, 'bitcoin'); + let calculatedBitcoinFeeRate: string | undefined; + + [transaction, transferAmount.value, calculatedBitcoinFeeRate] = await createBitcoinTransaction({ bitcoinApi, + chain, + from, + feeInfo, + to, + transferAll: !!transferAll, + value: txVal, + network: network }); + + if (calculatedBitcoinFeeRate) { + const feeRate = parseFloat(calculatedBitcoinFeeRate); + + if (!isNaN(feeRate)) { + overrideFeeCustom = { feeRate }; + } + } + + // TODO: This is a hotfix until transferMax for Bitcoin is supported. + if (transferAll) { + inputData.value = transferAmount.value; + } } else { const substrateApi = this.#koniState.getSubstrateApi(chain); @@ -1592,8 +1628,8 @@ export default class KoniExtension { warnings, address: from, chain, - feeCustom, - feeOption, + feeCustom: overrideFeeCustom || feeCustom, + feeOption: overrideFeeCustom ? 'custom' : feeOption, tokenPayFeeSlug, chainType, transferNativeAmount, @@ -2086,7 +2122,7 @@ export default class KoniExtension { } private async subscribeMaxTransferable (request: RequestSubscribeTransfer, id: string, port: chrome.runtime.Port): Promise { - const { address, chain, destChain: _destChain, feeCustom, feeOption, token, tokenPayFeeSlug, value } = request; + const { address, chain, destChain: _destChain, feeCustom, feeOption, to, token, tokenPayFeeSlug, value } = request; const cb = createSubscription<'pri(transfer.subscribe)'>(id, port); const transferTokenInfo = this.#koniState.chainService.getAssetBySlug(token); @@ -2109,6 +2145,7 @@ export default class KoniExtension { const _request: CalculateMaxTransferable = { address: address, + to: to, value, // todo: lazy subscribe to improve performance cardanoApi: this.#koniState.chainService.getCardanoApi(chain), destChain, @@ -2120,6 +2157,7 @@ export default class KoniExtension { srcToken, substrateApi: this.#koniState.chainService.getSubstrateApi(chain), tonApi: this.#koniState.chainService.getTonApi(chain), + bitcoinApi: this.#koniState.chainService.getBitcoinApi(chain), isTransferLocalTokenAndPayThatTokenAsFee, isTransferNativeTokenAndPayLocalTokenAsFee, nativeToken @@ -2342,6 +2380,20 @@ export default class KoniExtension { return this.#koniState.getConfirmationsQueueSubjectCardano().getValue(); } + private subscribeConfirmationsBitcoin (id: string, port: chrome.runtime.Port) { + const cb = createSubscription<'pri(confirmationsBitcoin.subscribe)'>(id, port); + + const subscription = this.#koniState.getConfirmationsQueueSubjectBitcoin().subscribe(cb); + + this.createUnsubscriptionHandle(id, subscription.unsubscribe); + + port.onDisconnect.addListener((): void => { + this.cancelSubscription(id); + }); + + return this.#koniState.getConfirmationsQueueSubjectBitcoin().getValue(); + } + private async completeConfirmation (request: RequestConfirmationComplete) { return await this.#koniState.completeConfirmation(request); } @@ -2354,6 +2406,9 @@ export default class KoniExtension { return await this.#koniState.completeConfirmationCardano(request); } + private async completeConfirmationBitcoin (request: RequestConfirmationCompleteBitcoin) { + return await this.#koniState.completeConfirmationBitcoin(request); + } /// Sign Qr private getNetworkJsonByChainId (chainId?: number): _ChainInfo | null { @@ -5193,12 +5248,16 @@ export default class KoniExtension { return this.subscribeConfirmationsTon(id, port); case 'pri(confirmationsCardano.subscribe)': return this.subscribeConfirmationsCardano(id, port); + case 'pri(confirmationsBitcoin.subscribe)': + return this.subscribeConfirmationsBitcoin(id, port); case 'pri(confirmations.complete)': return await this.completeConfirmation(request as RequestConfirmationComplete); case 'pri(confirmationsTon.complete)': return await this.completeConfirmationTon(request as RequestConfirmationCompleteTon); case 'pri(confirmationsCardano.complete)': return await this.completeConfirmationCardano(request as RequestConfirmationCompleteCardano); + case 'pri(confirmationsBitcoin.complete)': + return await this.completeConfirmationBitcoin(request as RequestConfirmationCompleteBitcoin); /// Stake case 'pri(bonding.getBondingOptions)': diff --git a/packages/extension-base/src/koni/background/handlers/State.ts b/packages/extension-base/src/koni/background/handlers/State.ts index bc819acdbba..e9e885aeaf3 100644 --- a/packages/extension-base/src/koni/background/handlers/State.ts +++ b/packages/extension-base/src/koni/background/handlers/State.ts @@ -8,7 +8,7 @@ import { EvmProviderError } from '@subwallet/extension-base/background/errors/Ev import { TransactionError } from '@subwallet/extension-base/background/errors/TransactionError'; import { withErrorLog } from '@subwallet/extension-base/background/handlers/helpers'; import { isSubscriptionRunning, unsubscribe } from '@subwallet/extension-base/background/handlers/subscriptions'; -import { AddressCardanoTransactionBalance, AddTokenRequestExternal, AmountData, APIItemState, ApiMap, AuthRequestV2, CardanoKeyType, CardanoProviderErrorType, CardanoSignatureRequest, CardanoTransactionDappConfig, ChainStakingMetadata, ChainType, ConfirmationsQueue, ConfirmationsQueueCardano, ConfirmationsQueueTon, ConfirmationType, CrowdloanItem, CrowdloanJson, CurrencyType, EvmProviderErrorType, EvmSendTransactionParams, EvmSendTransactionRequest, EvmSignatureRequest, ExternalRequestPromise, ExternalRequestPromiseStatus, ExtrinsicType, MantaAuthorizationContext, MantaPayConfig, MantaPaySyncState, NftCollection, NftItem, NftJson, NominatorMetadata, RequestAccountExportPrivateKey, RequestCardanoSignData, RequestCardanoSignTransaction, RequestConfirmationComplete, RequestConfirmationCompleteCardano, RequestConfirmationCompleteTon, RequestCrowdloanContributions, RequestSettingsType, ResponseAccountExportPrivateKey, ResponseCardanoSignData, ResponseCardanoSignTransaction, ServiceInfo, SingleModeJson, StakingItem, StakingJson, StakingRewardItem, StakingRewardJson, StakingType, UiSettings } from '@subwallet/extension-base/background/KoniTypes'; +import { AddressCardanoTransactionBalance, AddTokenRequestExternal, AmountData, APIItemState, ApiMap, AuthRequestV2, CardanoKeyType, CardanoProviderErrorType, CardanoSignatureRequest, CardanoTransactionDappConfig, ChainStakingMetadata, ChainType, ConfirmationsQueue, ConfirmationsQueueBitcoin, ConfirmationsQueueCardano, ConfirmationsQueueTon, ConfirmationType, CrowdloanItem, CrowdloanJson, CurrencyType, EvmProviderErrorType, EvmSendTransactionParams, EvmSendTransactionRequest, EvmSignatureRequest, ExternalRequestPromise, ExternalRequestPromiseStatus, ExtrinsicType, MantaAuthorizationContext, MantaPayConfig, MantaPaySyncState, NftCollection, NftItem, NftJson, NominatorMetadata, RequestAccountExportPrivateKey, RequestCardanoSignData, RequestCardanoSignTransaction, RequestConfirmationComplete, RequestConfirmationCompleteBitcoin, RequestConfirmationCompleteCardano, RequestConfirmationCompleteTon, RequestCrowdloanContributions, RequestSettingsType, ResponseAccountExportPrivateKey, ResponseCardanoSignData, ResponseCardanoSignTransaction, ServiceInfo, SingleModeJson, StakingItem, StakingJson, StakingRewardItem, StakingRewardJson, StakingType, UiSettings } from '@subwallet/extension-base/background/KoniTypes'; import { RequestAuthorizeTab, RequestRpcSend, RequestRpcSubscribe, RequestRpcUnsubscribe, RequestSign, ResponseRpcListProviders, ResponseSigning } from '@subwallet/extension-base/background/types'; import { BACKEND_API_URL, BACKEND_PRICE_HISTORY_URL, EnvConfig, MANTA_PAY_BALANCE_INTERVAL, REMIND_EXPORT_ACCOUNT } from '@subwallet/extension-base/constants'; import { convertErrorFormat, generateValidationProcess, PayloadValidated, ValidateStepFunction, validationAuthCardanoMiddleware, validationAuthMiddleware, validationAuthWCMiddleware, validationCardanoSignDataMiddleware, validationConnectMiddleware, validationEvmDataTransactionMiddleware, validationEvmSignMessageMiddleware } from '@subwallet/extension-base/core/logic-validation'; @@ -144,6 +144,7 @@ export default class KoniState { private generalStatus: ServiceStatus = ServiceStatus.INITIALIZING; private waitSleeping: Promise | null = null; private waitStarting: Promise | null = null; + private waitStartingFull: Promise | null = null; constructor (providers: Providers = {}) { // Init subwallet api sdk @@ -157,12 +158,14 @@ export default class KoniState { this.eventService = new EventService(); this.dbService = new DatabaseService(this.eventService); this.keyringService = new KeyringService(this); + this.feeService = new FeeService(this); + this.transactionService = new TransactionService(this); this.notificationService = new NotificationService(); this.chainService = new ChainService(this.dbService, this.eventService); this.subscanService = SubscanService.getInstance(); this.settingService = new SettingService(); - this.requestService = new RequestService(this.chainService, this.settingService, this.keyringService); + this.requestService = new RequestService(this.chainService, this.settingService, this.keyringService, this.feeService, this.transactionService); this.priceService = new PriceService(this.dbService, this.eventService, this.chainService); this.balanceService = new BalanceService(this); this.historyService = new HistoryService(this.dbService, this.chainService, this.eventService, this.keyringService, this.subscanService); @@ -173,9 +176,7 @@ export default class KoniState { this.campaignService = new CampaignService(this); this.mktCampaignService = new MktCampaignService(this); this.buyService = new BuyService(this); - this.transactionService = new TransactionService(this); this.earningService = new EarningService(this); - this.feeService = new FeeService(this); this.swapService = new SwapService(this); this.inappNotificationService = new InappNotificationService(this.dbService, this.keyringService, this.eventService, this.chainService); this.chainOnlineService = new ChainOnlineService(this.chainService, this.settingService, this.eventService, this.dbService); @@ -186,7 +187,9 @@ export default class KoniState { // Init state if (targetIsWeb) { - this.init().catch(console.error); + this.init().then(() => { + this.wakeup(true).catch(console.error); + }).catch(console.error); } } @@ -306,7 +309,7 @@ export default class KoniState { await this.swapService.init(); await this.inappNotificationService.init(); - this.onReady(); + // this.onReady(); this.onAccountAdd(); this.onAccountRemove(); @@ -319,6 +322,9 @@ export default class KoniState { this.chainService.subscribeChainInfoMap().subscribe(() => { this.afterChainServiceInit(); }); + + // Mark app is ready + this.eventService.emit('general.init', true); } public async initMantaPay (password: string) { @@ -347,12 +353,6 @@ export default class KoniState { }); } - public onReady () { - // Todo: Need optimize in the future to, only run important services onetime to save resources - // Todo: If optimize must check activity of web-runner of mobile - this._start().catch(console.error); - } - public updateKeyringState (isReady = true, callback?: () => void): void { this.keyringService.updateKeyringState(isReady); callback && callback(); @@ -952,6 +952,10 @@ export default class KoniState { return this.chainService.getCardanoApi(networkKey); } + public getBitcoinApi (networkKey: string) { + return this.chainService.getBitcoinApi(networkKey); + } + public getApiMap () { return { substrate: this.chainService.getSubstrateApiMap(), @@ -1548,6 +1552,10 @@ export default class KoniState { return this.requestService.confirmationsQueueSubjectCardano; } + public getConfirmationsQueueSubjectBitcoin (): BehaviorSubject { + return this.requestService.confirmationsQueueSubjectBitcoin; + } + public async completeConfirmation (request: RequestConfirmationComplete) { return await this.requestService.completeConfirmation(request); } @@ -1560,6 +1568,10 @@ export default class KoniState { return await this.requestService.completeConfirmationCardano(request); } + public async completeConfirmationBitcoin (request: RequestConfirmationCompleteBitcoin) { + return await this.requestService.completeConfirmationBitcoin(request); + } + private async onMV3Update () { const migrationStatus = await SWStorage.instance.getItem('mv3_migration'); @@ -1737,8 +1749,12 @@ export default class KoniState { } public async sleep () { + // Wait for app initialized before sleep + await this.eventService.waitAppInitialized; + // Wait starting finish before sleep to avoid conflict this.generalStatus === ServiceStatus.STARTING && this.waitStarting && await this.waitStarting; + this.generalStatus === ServiceStatus.STARTING_FULL && this.waitStartingFull && await this.waitStartingFull; this.eventService.emit('general.sleep', true); // Avoid sleep multiple times @@ -1771,6 +1787,9 @@ export default class KoniState { } private async _start () { + // Wait for app initialized before start + await this.eventService.waitAppInitialized; + // Wait sleep finish before start to avoid conflict this.generalStatus === ServiceStatus.STOPPING && this.waitSleeping && await this.waitSleeping; @@ -1799,7 +1818,7 @@ export default class KoniState { } // Start services - await Promise.all([this.cron.start(), this.subscription.start(), this.historyService.start(), this.priceService.start(), this.balanceService.start(), this.earningService.start(), this.swapService.start(), this.inappNotificationService.start()]); + this.eventService.emit('general.start', true); // Complete starting starting.resolve(); @@ -1807,8 +1826,36 @@ export default class KoniState { this.generalStatus = ServiceStatus.STARTED; } - public async wakeup () { + private async _startFull () { + // Continue wait existed starting process + if (this.generalStatus === ServiceStatus.STARTING) { + await this.waitStarting; + } + + // Always start full from start state + if (this.generalStatus !== ServiceStatus.STARTED) { + return; + } + + this.generalStatus = ServiceStatus.STARTING_FULL; + const startingFull = createPromiseHandler(); + + this.waitStartingFull = startingFull.promise; + + await Promise.all([this.cron.start(), this.subscription.start(), this.historyService.start(), this.priceService.start(), this.balanceService.start(), this.earningService.start(), this.swapService.start(), this.inappNotificationService.start()]); + this.eventService.emit('general.start_full', true); + + this.waitStartingFull = null; + this.generalStatus = ServiceStatus.STARTED_FULL; + startingFull.resolve(); + } + + public async wakeup (fullWakeup = false) { await this._start(); + + if (fullWakeup) { + await this._startFull(); + } } public cancelSubscription (id: string): boolean { diff --git a/packages/extension-base/src/services/balance-service/helpers/subscribe/bitcoin.ts b/packages/extension-base/src/services/balance-service/helpers/subscribe/bitcoin.ts new file mode 100644 index 00000000000..8d32a050982 --- /dev/null +++ b/packages/extension-base/src/services/balance-service/helpers/subscribe/bitcoin.ts @@ -0,0 +1,100 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +import { _AssetType } from '@subwallet/chain-list/types'; +import { AddressBalanceResult, APIItemState, BitcoinBalanceMetadata } from '@subwallet/extension-base/background/KoniTypes'; +import { BITCOIN_REFRESH_BALANCE_INTERVAL } from '@subwallet/extension-base/constants'; +import { _BitcoinApi } from '@subwallet/extension-base/services/chain-service/types'; +import { BalanceItem, SusbcribeBitcoinPalletBalance } from '@subwallet/extension-base/types'; +import { filterAssetsByChainAndType } from '@subwallet/extension-base/utils'; + +function getDefaultBalanceResult (): AddressBalanceResult { + return { + balance: '0', + bitcoinBalanceMetadata: { + inscriptionCount: 0, + runeBalance: '0', + inscriptionBalance: '0' + } + }; +} + +async function getBitcoinBalance (bitcoinApi: _BitcoinApi, addresses: string[]) { + return await Promise.all(addresses.map(async (address) => { + try { + const [addressSummaryInfo] = await Promise.all([ + bitcoinApi.api.getAddressSummaryInfo(address) + ]); + + if (Number(addressSummaryInfo.balance) < 0) { + return getDefaultBalanceResult(); + } + + const bitcoinBalanceMetadata = { + inscriptionCount: addressSummaryInfo.total_inscription, + runeBalance: addressSummaryInfo.balance_rune, + inscriptionBalance: addressSummaryInfo.balance_inscription + } as BitcoinBalanceMetadata; + + return { + balance: addressSummaryInfo.balance.toString(), + bitcoinBalanceMetadata: bitcoinBalanceMetadata + }; + } catch (error) { + console.log('Error while fetching Bitcoin balances', error); + + return getDefaultBalanceResult(); + } + })); +} + +export function subscribeBitcoinBalance (params: SusbcribeBitcoinPalletBalance): () => void { + const { addresses, assetMap, bitcoinApi, callback, chainInfo } = params; + const chain = chainInfo.slug; + const nativeTokenInfo = filterAssetsByChainAndType(assetMap, chain, [_AssetType.NATIVE]); + const nativeTokenSlug = Object.values(nativeTokenInfo)[0]?.slug || ''; + + const getBalance = () => { + getBitcoinBalance(bitcoinApi, addresses) + .then((balances) => { + return balances.map(({ balance, bitcoinBalanceMetadata }, index): BalanceItem => { + return { + address: addresses[index], + tokenSlug: nativeTokenSlug, + state: APIItemState.READY, + free: balance, + locked: ( + parseInt(bitcoinBalanceMetadata.runeBalance.toString()) + + parseInt(bitcoinBalanceMetadata.inscriptionBalance.toString()) + ).toString(), + metadata: bitcoinBalanceMetadata + }; + }); + }) + .catch((e) => { + console.error('Error on get Bitcoin balance with token bitcoin', e); + + return addresses.map((address): BalanceItem => { + return { + address: address, + tokenSlug: nativeTokenSlug, + state: APIItemState.READY, + free: '0', + locked: '0' + }; + }); + }) + .then((items) => { + callback(items); + }) + .catch(console.error); + }; + + const interval = setInterval(getBalance, BITCOIN_REFRESH_BALANCE_INTERVAL); + + getBalance(); + + return () => { + clearInterval(interval); + }; +} diff --git a/packages/extension-base/src/services/balance-service/helpers/subscribe/index.ts b/packages/extension-base/src/services/balance-service/helpers/subscribe/index.ts index 71eb63b1953..1721b508398 100644 --- a/packages/extension-base/src/services/balance-service/helpers/subscribe/index.ts +++ b/packages/extension-base/src/services/balance-service/helpers/subscribe/index.ts @@ -3,9 +3,10 @@ import { _AssetType, _ChainAsset, _ChainInfo } from '@subwallet/chain-list/types'; import { APIItemState, ExtrinsicType } from '@subwallet/extension-base/background/KoniTypes'; +import { subscribeBitcoinBalance } from '@subwallet/extension-base/services/balance-service/helpers/subscribe/bitcoin'; import { subscribeCardanoBalance } from '@subwallet/extension-base/services/balance-service/helpers/subscribe/cardano'; -import { _CardanoApi, _EvmApi, _SubstrateApi, _TonApi } from '@subwallet/extension-base/services/chain-service/types'; -import { _getSubstrateGenesisHash, _isChainBitcoinCompatible, _isChainCardanoCompatible, _isChainEvmCompatible, _isChainTonCompatible, _isPureCardanoChain, _isPureEvmChain, _isPureTonChain } from '@subwallet/extension-base/services/chain-service/utils'; +import { _BitcoinApi, _CardanoApi, _EvmApi, _SubstrateApi, _TonApi } from '@subwallet/extension-base/services/chain-service/types'; +import { _getSubstrateGenesisHash, _isChainBitcoinCompatible, _isChainCardanoCompatible, _isChainEvmCompatible, _isChainTonCompatible, _isPureBitcoinChain, _isPureCardanoChain, _isPureEvmChain, _isPureTonChain } from '@subwallet/extension-base/services/chain-service/utils'; import { AccountJson, BalanceItem } from '@subwallet/extension-base/types'; import { filterAssetsByChainAndType, getAddressesByChainTypeMap, pairToAccount } from '@subwallet/extension-base/utils'; import keyring from '@subwallet/ui-keyring'; @@ -41,16 +42,16 @@ export const getAccountJsonByAddress = (address: string): AccountJson | null => /** Filter addresses to subscribe by chain info */ const filterAddress = (addresses: string[], chainInfo: _ChainInfo): [string[], string[]] => { - const { bitcoin, cardano, evm, substrate, ton } = getAddressesByChainTypeMap(addresses); + const { _bitcoin, bitcoin, cardano, evm, substrate, ton } = getAddressesByChainTypeMap(addresses, chainInfo); if (_isChainEvmCompatible(chainInfo)) { - return [evm, [...bitcoin, ...substrate, ...ton, ...cardano]]; + return [evm, [bitcoin, substrate, ton, cardano, _bitcoin].flat()]; } else if (_isChainBitcoinCompatible(chainInfo)) { - return [bitcoin, [...evm, ...substrate, ...ton, ...cardano]]; + return [bitcoin, [evm, substrate, ton, cardano, _bitcoin].flat()]; } else if (_isChainTonCompatible(chainInfo)) { - return [ton, [...bitcoin, ...evm, ...substrate, ...cardano]]; + return [ton, [bitcoin, evm, substrate, cardano, _bitcoin].flat()]; } else if (_isChainCardanoCompatible(chainInfo)) { - return [cardano, [...bitcoin, ...evm, ...substrate, ...ton]]; + return [cardano, [bitcoin, evm, substrate, ton, _bitcoin].flat()]; } else { const fetchList: string[] = []; const unfetchList: string[] = []; @@ -80,7 +81,7 @@ const filterAddress = (addresses: string[], chainInfo: _ChainInfo): [string[], s } }); - return [fetchList, [...unfetchList, ...bitcoin, ...evm, ...ton, ...cardano]]; + return [fetchList, [unfetchList, bitcoin, evm, ton, cardano, _bitcoin].flat()]; } }; @@ -129,6 +130,7 @@ export function subscribeBalance ( evmApiMap: Record, tonApiMap: Record, cardanoApiMap: Record, + bitcoinApiMap: Record, callback: (rs: BalanceItem[]) => void, extrinsicType?: ExtrinsicType ) { @@ -187,6 +189,18 @@ export function subscribeBalance ( }); } + const bitcoinApi = bitcoinApiMap[chainSlug]; + + if (_isPureBitcoinChain(chainInfo)) { + return subscribeBitcoinBalance({ + addresses: useAddresses, + assetMap: chainAssetMap, + bitcoinApi, + callback, + chainInfo + }); + } + // If the chain is not ready, return pending state if (!substrateApiMap[chainSlug].isApiReady) { handleUnsupportedOrPendingAddresses( diff --git a/packages/extension-base/src/services/balance-service/index.ts b/packages/extension-base/src/services/balance-service/index.ts index aebc3f88888..8fad007dd46 100644 --- a/packages/extension-base/src/services/balance-service/index.ts +++ b/packages/extension-base/src/services/balance-service/index.ts @@ -42,6 +42,9 @@ export class BalanceService implements StoppableServiceInterface { status: ServiceStatus = ServiceStatus.NOT_INITIALIZED; private isReload = false; + get isStarted (): boolean { + return this.status === ServiceStatus.STARTED; + } private readonly detectAccountBalanceStore = new DetectAccountBalanceStore(); private readonly balanceDetectSubject: BehaviorSubject = new BehaviorSubject({}); @@ -69,7 +72,7 @@ export class BalanceService implements StoppableServiceInterface { this.status = ServiceStatus.INITIALIZED; // Start service - await this.start(); + // await this.start(); // Commented out to avoid auto start when app not fully initialized // Handle events this.state.eventService.onLazy(this.handleEvents.bind(this)); @@ -166,7 +169,7 @@ export class BalanceService implements StoppableServiceInterface { if (needReload) { addLazy('reloadBalanceByEvents', () => { - if (!this.isReload) { + if (!this.isReload && this.isStarted) { this.runSubscribeBalances().catch(console.error); } }, lazyTime, undefined, true); @@ -223,10 +226,11 @@ export class BalanceService implements StoppableServiceInterface { const substrateApiMap = this.state.chainService.getSubstrateApiMap(); const tonApiMap = this.state.chainService.getTonApiMap(); const cardanoApiMap = this.state.chainService.getCardanoApiMap(); + const bitcoinApiMap = this.state.chainService.getBitcoinApiMap(); let unsub = noop; - unsub = subscribeBalance([address], [chain], [tSlug], assetMap, chainInfoMap, substrateApiMap, evmApiMap, tonApiMap, cardanoApiMap, (result) => { + unsub = subscribeBalance([address], [chain], [tSlug], assetMap, chainInfoMap, substrateApiMap, evmApiMap, tonApiMap, cardanoApiMap, bitcoinApiMap, (result) => { const rs = result[0]; let value: string; @@ -419,7 +423,7 @@ export class BalanceService implements StoppableServiceInterface { const substrateApiMap = this.state.chainService.getSubstrateApiMap(); const tonApiMap = this.state.chainService.getTonApiMap(); const cardanoApiMap = this.state.chainService.getCardanoApiMap(); - + const bitcoinApiMap = this.state.chainService.getBitcoinApiMap(); const activeChainSlugs = Object.keys(this.state.getActiveChainInfoMap()); const assetState = this.state.chainService.subscribeAssetSettings().value; const assets: string[] = Object.values(assetMap) @@ -428,7 +432,7 @@ export class BalanceService implements StoppableServiceInterface { }) .map((asset) => asset.slug); - const unsub = subscribeBalance(addresses, activeChainSlugs, assets, assetMap, chainInfoMap, substrateApiMap, evmApiMap, tonApiMap, cardanoApiMap, (result) => { + const unsub = subscribeBalance(addresses, activeChainSlugs, assets, assetMap, chainInfoMap, substrateApiMap, evmApiMap, tonApiMap, cardanoApiMap, bitcoinApiMap, (result) => { !cancel && this.setBalanceItem(result); }, ExtrinsicType.TRANSFER_BALANCE); @@ -441,6 +445,46 @@ export class BalanceService implements StoppableServiceInterface { }; } + async refreshBalanceForAddress (address: string, chain: string, asset: string, extrinsicType?: ExtrinsicType) { + // Check if address and chain are valid + const chainInfoMap = this.state.chainService.getChainInfoMap(); + + if (!chainInfoMap[chain]) { + console.warn(`Chain ${chain} is not supported`); + + return; + } + + // Get necessary data + const assetMap = this.state.chainService.getAssetRegistry(); + const evmApiMap = this.state.chainService.getEvmApiMap(); + const substrateApiMap = this.state.chainService.getSubstrateApiMap(); + const tonApiMap = this.state.chainService.getTonApiMap(); + const cardanoApiMap = this.state.chainService.getCardanoApiMap(); + const bitcoinApiMap = this.state.chainService.getBitcoinApiMap(); + + return new Promise((resolve) => { + const unsub = subscribeBalance( + [address], + [chain], + [asset], + assetMap, + chainInfoMap, + substrateApiMap, + evmApiMap, + tonApiMap, + cardanoApiMap, + bitcoinApiMap, + (result) => { + this.setBalanceItem(result); + unsub(); + resolve(); + }, + extrinsicType || ExtrinsicType.TRANSFER_BALANCE + ); + }); + } + /** Unsubscribe balance subscription */ runUnsubscribeBalances () { this._unsubscribeBalance && this._unsubscribeBalance(); diff --git a/packages/extension-base/src/services/balance-service/transfer/bitcoin-transfer.ts b/packages/extension-base/src/services/balance-service/transfer/bitcoin-transfer.ts new file mode 100644 index 00000000000..bb540282bc3 --- /dev/null +++ b/packages/extension-base/src/services/balance-service/transfer/bitcoin-transfer.ts @@ -0,0 +1,122 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +import { TransactionError } from '@subwallet/extension-base/background/errors/TransactionError'; +import { _BITCOIN_CHAIN_SLUG, _BITCOIN_NAME, _BITCOIN_TESTNET_NAME } from '@subwallet/extension-base/services/chain-service/constants'; +import { _BitcoinApi } from '@subwallet/extension-base/services/chain-service/types'; +import { BitcoinFeeInfo, BitcoinFeeRate, FeeInfo, TransactionFee } from '@subwallet/extension-base/types'; +import { combineBitcoinFee, determineUtxosForSpend, determineUtxosForSpendAll, getTransferableBitcoinUtxos } from '@subwallet/extension-base/utils'; +import { BitcoinAddressType } from '@subwallet/keyring/types'; +import { getBitcoinAddressInfo } from '@subwallet/keyring/utils'; +import { keyring } from '@subwallet/ui-keyring'; +import BigN from 'bignumber.js'; +import { Network, Psbt } from 'bitcoinjs-lib'; + +export interface TransferBitcoinProps extends TransactionFee { + bitcoinApi: _BitcoinApi; + chain: string; + from: string; + feeInfo: FeeInfo; + to: string; + transferAll: boolean; + value: string; + network: Network +} + +export async function createBitcoinTransaction (params: TransferBitcoinProps): Promise<[Psbt, string, string|undefined]> { + const { bitcoinApi, chain, feeCustom: _feeCustom, feeInfo: _feeInfo, feeOption, from, network, to, transferAll, value } = params; + const feeCustom = _feeCustom as BitcoinFeeRate; + + const feeInfo = _feeInfo as BitcoinFeeInfo; + const bitcoinFee = combineBitcoinFee(feeInfo, feeOption, feeCustom); + const utxos = await getTransferableBitcoinUtxos(bitcoinApi, from); + + try { + const amountValue = parseFloat(value); + + const determineUtxosArgs = { + amount: amountValue, + feeRate: bitcoinFee.feeRate, + recipient: to, + sender: from, + utxos + }; + + const { fee, inputs, isCustomFeeRate, outputs, size } = transferAll + ? determineUtxosForSpendAll(determineUtxosArgs) + : determineUtxosForSpend(determineUtxosArgs); + + const pair = keyring.getPair(from); + const tx = new Psbt({ network }); + let transferAmount = new BigN(0); + + for (const input of inputs) { + const addressInfo = getBitcoinAddressInfo(pair.address); + + if (addressInfo.type === BitcoinAddressType.p2pkh || addressInfo.type === BitcoinAddressType.p2sh) { + // BIP-44 (Legacy) + const hex = await bitcoinApi.api.getTxHex(input.txid); + + tx.addInput({ + hash: input.txid, + index: input.vout, + nonWitnessUtxo: Buffer.from(hex, 'hex') + }); + } else if (addressInfo.type === BitcoinAddressType.p2wpkh) { + // BIP-84 (Native SegWit) + tx.addInput({ + hash: input.txid, + index: input.vout, + witnessUtxo: { + script: pair.bitcoin.output, + value: input.value + } + }); + } else if (addressInfo.type === BitcoinAddressType.p2tr) { + // BIP-86 (Taproot) + tx.addInput({ + hash: input.txid, + index: input.vout, + witnessUtxo: { + script: pair.bitcoin.output, + value: input.value // UTXO value in satoshis + }, + tapInternalKey: pair.bitcoin.internalPubkey // X-only public key (32 bytes) + }); + } else { + throw new Error(`Unsupported address type: ${addressInfo.type}`); + } + } + + for (const output of outputs) { + tx.addOutput({ + address: output.address || from, + value: output.value + }); + + if (output.address === to) { + transferAmount = transferAmount.plus(output.value); + } + } + + const customFeeRate = fee / size; + const customFeeRateResult = isCustomFeeRate ? customFeeRate.toString() : undefined; + + return [tx, transferAmount.toString(), customFeeRateResult]; + } catch (e) { + if (e instanceof TransactionError) { + throw e; + } + + console.warn('Failed to create Bitcoin transaction:', e); + throw new Error(`You don’t have enough BTC (${convertChainToSymbol(chain)}) for the transaction. Lower your BTC amount and try again`); + } +} + +function convertChainToSymbol (chain: string) { + if (chain === _BITCOIN_CHAIN_SLUG) { + return _BITCOIN_NAME; + } else { + return _BITCOIN_TESTNET_NAME; + } +} diff --git a/packages/extension-base/src/services/base/types.ts b/packages/extension-base/src/services/base/types.ts index 66f41f3347e..5f1851c25c0 100644 --- a/packages/extension-base/src/services/base/types.ts +++ b/packages/extension-base/src/services/base/types.ts @@ -10,6 +10,8 @@ export enum ServiceStatus { INITIALIZED = 'initialized', STARTED = 'started', STARTING = 'starting', + STARTED_FULL = 'started_full', + STARTING_FULL = 'starting_full', STOPPED = 'stopped', STOPPING = 'stopping', } diff --git a/packages/extension-base/src/services/chain-service/constants.ts b/packages/extension-base/src/services/chain-service/constants.ts index a807658cffd..9dc18c4c640 100644 --- a/packages/extension-base/src/services/chain-service/constants.ts +++ b/packages/extension-base/src/services/chain-service/constants.ts @@ -298,9 +298,14 @@ export const LATEST_CHAIN_DATA_FETCHING_INTERVAL = 120000; // TODO: review const TARGET_BRANCH = process.env.NODE_ENV !== 'production' ? 'koni-dev' : 'master'; +export const _BITCOIN_CHAIN_SLUG = 'bitcoin'; +export const _BITCOIN_TESTNET_CHAIN_SLUG = 'bitcoinTestnet'; +export const _BITCOIN_NAME = 'Bitcoin'; +export const _BITCOIN_TESTNET_NAME = 'Bitcoin Testnet'; export const _CHAIN_INFO_SRC = `https://raw.githubusercontent.com/Koniverse/SubWallet-Chain/${TARGET_BRANCH}/packages/chain-list/src/data/ChainInfo.json`; export const _CHAIN_ASSET_SRC = `https://raw.githubusercontent.com/Koniverse/SubWallet-Chain/${TARGET_BRANCH}/packages/chain-list/src/data/ChainAsset.json`; export const _ASSET_REF_SRC = `https://raw.githubusercontent.com/Koniverse/SubWallet-Chain/${TARGET_BRANCH}/packages/chain-list/src/data/AssetRef.json`; export const _MULTI_CHAIN_ASSET_SRC = `https://raw.githubusercontent.com/Koniverse/SubWallet-Chain/${TARGET_BRANCH}/packages/chain-list/src/data/MultiChainAsset.json`; export const _CHAIN_LOGO_MAP_SRC = `https://raw.githubusercontent.com/Koniverse/SubWallet-Chain/${TARGET_BRANCH}/packages/chain-list/src/data/ChainLogoMap.json`; export const _ASSET_LOGO_MAP_SRC = `https://raw.githubusercontent.com/Koniverse/SubWallet-Chain/${TARGET_BRANCH}/packages/chain-list/src/data/AssetLogoMap.json`; +export const _BTC_SERVICE_TOKEN = process.env.BTC_SERVICE_TOKEN || ''; diff --git a/packages/extension-base/src/services/chain-service/handler/bitcoin/BitcoinApi.ts b/packages/extension-base/src/services/chain-service/handler/bitcoin/BitcoinApi.ts new file mode 100644 index 00000000000..be34e6a6056 --- /dev/null +++ b/packages/extension-base/src/services/chain-service/handler/bitcoin/BitcoinApi.ts @@ -0,0 +1,135 @@ +// Copyright 2019-2022 @subwallet/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { BlockStreamTestnetRequestStrategy, MempoolTestnetRequestStrategy } from '@subwallet/extension-base/services/chain-service/handler/bitcoin/strategy/BlockStreamTestnet'; +import { SubWalletMainnetRequestStrategy } from '@subwallet/extension-base/services/chain-service/handler/bitcoin/strategy/SubWalletMainnet'; +import { BitcoinApiStrategy } from '@subwallet/extension-base/services/chain-service/handler/bitcoin/strategy/types'; +import { createPromiseHandler, PromiseHandler } from '@subwallet/extension-base/utils/promise'; +import { BehaviorSubject } from 'rxjs'; + +import { _ApiOptions } from '../../handler/types'; +import { _BitcoinApi, _ChainConnectionStatus } from '../../types'; + +// const isBlockStreamProvider = (apiUrl: string): boolean => apiUrl === 'https://blockstream-testnet.openbit.app' || apiUrl === 'https://electrs.openbit.app'; +// const BLOCKSTREAM_TESTNET_API_URL = 'https://blockstream.info/testnet/api/'; +// const MEMPOOL_TESTNET_V4_API_URL = 'https://mempool.space/testnet4/api/'; + +export class BitcoinApi implements _BitcoinApi { + chainSlug: string; + apiUrl: string; + apiError?: string; + apiRetry = 0; + public readonly isApiConnectedSubject = new BehaviorSubject(false); + public readonly connectionStatusSubject = new BehaviorSubject(_ChainConnectionStatus.DISCONNECTED); + isApiReady = false; + isApiReadyOnce = false; + isReadyHandler: PromiseHandler<_BitcoinApi>; + + providerName: string; + api: BitcoinApiStrategy; + + constructor (chainSlug: string, apiUrl: string, { providerName }: _ApiOptions = {}) { + this.chainSlug = chainSlug; + this.apiUrl = apiUrl; + this.providerName = providerName || 'unknown'; + this.isReadyHandler = createPromiseHandler<_BitcoinApi>(); + this.api = this.createApiStrategy(apiUrl); + + this.connect(); + } + + get isApiConnected (): boolean { + return this.isApiConnectedSubject.getValue(); + } + + private createApiStrategy (apiUrl: string): BitcoinApiStrategy { + const isTestnet = apiUrl.includes('testnet'); + const isBlockstreamUrl = apiUrl.includes('blockstream'); + + if (isTestnet) { + return isBlockstreamUrl + ? new BlockStreamTestnetRequestStrategy(apiUrl) + : new MempoolTestnetRequestStrategy(apiUrl); + } + + return new SubWalletMainnetRequestStrategy(apiUrl); + } + + get connectionStatus (): _ChainConnectionStatus { + return this.connectionStatusSubject.getValue(); + } + + private updateConnectionStatus (status: _ChainConnectionStatus): void { + const isConnected = status === _ChainConnectionStatus.CONNECTED; + + if (isConnected !== this.isApiConnectedSubject.value) { + this.isApiConnectedSubject.next(isConnected); + } + + if (status !== this.connectionStatusSubject.value) { + this.connectionStatusSubject.next(status); + } + } + + get isReady (): Promise<_BitcoinApi> { + return this.isReadyHandler.promise; + } + + async updateApiUrl (apiUrl: string) { + if (this.apiUrl === apiUrl) { + return; + } + + await this.disconnect(); + this.apiUrl = apiUrl; + + this.api = this.createApiStrategy(apiUrl); + + this.connect(); + } + + async recoverConnect () { + await this.isReadyHandler.promise; + } + + connect (): void { + this.updateConnectionStatus(_ChainConnectionStatus.CONNECTING); + + this.onConnect(); + } + + async disconnect () { + this.onDisconnect(); + + this.updateConnectionStatus(_ChainConnectionStatus.DISCONNECTED); + + return Promise.resolve(); + } + + destroy () { + return this.disconnect(); + } + + onConnect (): void { + if (!this.isApiConnected) { + console.log(`Connected to ${this.chainSlug} at ${this.apiUrl}`); + this.isApiReady = true; + + if (this.isApiReadyOnce) { + this.isReadyHandler.resolve(this); + } + } + + this.updateConnectionStatus(_ChainConnectionStatus.CONNECTED); + } + + onDisconnect (): void { + this.updateConnectionStatus(_ChainConnectionStatus.DISCONNECTED); + + if (this.isApiConnected) { + console.warn(`Disconnected from ${this.chainSlug} of ${this.apiUrl}`); + this.isApiReady = false; + this.isReadyHandler = createPromiseHandler<_BitcoinApi>(); + } + } +} diff --git a/packages/extension-base/src/services/chain-service/handler/bitcoin/BitcoinChainHandler.ts b/packages/extension-base/src/services/chain-service/handler/bitcoin/BitcoinChainHandler.ts new file mode 100644 index 00000000000..85da1abadfd --- /dev/null +++ b/packages/extension-base/src/services/chain-service/handler/bitcoin/BitcoinChainHandler.ts @@ -0,0 +1,90 @@ +// Copyright 2019-2022 @subwallet/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { ChainService } from '@subwallet/extension-base/services/chain-service'; + +import { AbstractChainHandler } from '../AbstractChainHandler'; +import { _ApiOptions } from '../types'; +import { BitcoinApi } from './BitcoinApi'; + +export class BitcoinChainHandler extends AbstractChainHandler { + private apiMap: Record = {}; + + // eslint-disable-next-line no-useless-constructor + constructor (parent?: ChainService) { + super(parent); + } + + public getApiMap () { + return this.apiMap; + } + + public getApiByChain (chain: string) { + return this.apiMap[chain]; + } + + public setApi (chainSlug: string, api: BitcoinApi) { + this.apiMap[chainSlug] = api; + } + + public async initApi (chainSlug: string, apiUrl: string, { onUpdateStatus, providerName }: Omit<_ApiOptions, 'metadata'> = {}) { + const existed = this.getApiByChain(chainSlug); + + if (existed) { + existed.connect(); + + if (apiUrl !== existed.apiUrl) { + existed.updateApiUrl(apiUrl).catch(console.error); + } + + return existed; + } + + const apiObject = new BitcoinApi(chainSlug, apiUrl, { providerName }); + + apiObject.connectionStatusSubject.subscribe(this.handleConnection.bind(this, chainSlug)); + apiObject.connectionStatusSubject.subscribe(onUpdateStatus); + + return Promise.resolve(apiObject); + } + + public async recoverApi (chainSlug: string): Promise { + const existed = this.getApiByChain(chainSlug); + + if (existed && !existed.isApiReadyOnce) { + console.log(`Reconnect ${existed.providerName || existed.chainSlug} at ${existed.apiUrl}`); + + return existed.recoverConnect(); + } + } + + destroyApi (chain: string) { + const api = this.getApiByChain(chain); + + api?.destroy().catch(console.error); + } + + async sleep () { + this.isSleeping = true; + this.cancelAllRecover(); + + await Promise.all(Object.values(this.getApiMap()).map((evmApi) => { + return evmApi.disconnect().catch(console.error); + })); + + return Promise.resolve(); + } + + wakeUp () { + this.isSleeping = false; + const activeChains = this.parent?.getActiveChains() || []; + + for (const chain of activeChains) { + const api = this.getApiByChain(chain); + + api?.connect(); + } + + return Promise.resolve(); + } +} diff --git a/packages/extension-base/src/services/chain-service/handler/bitcoin/strategy/BlockStreamTestnet/blockstream-testnet-strategy.ts b/packages/extension-base/src/services/chain-service/handler/bitcoin/strategy/BlockStreamTestnet/blockstream-testnet-strategy.ts new file mode 100644 index 00000000000..4e32b55d560 --- /dev/null +++ b/packages/extension-base/src/services/chain-service/handler/bitcoin/strategy/BlockStreamTestnet/blockstream-testnet-strategy.ts @@ -0,0 +1,419 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +import { SWError } from '@subwallet/extension-base/background/errors/SWError'; +import { BitcoinAddressSummaryInfo, BitcoinApiStrategy, BitcoinTransactionEventMap, BlockstreamAddressResponse, BlockStreamBlock, BlockStreamFeeEstimates, BlockStreamTransactionDetail, BlockStreamTransactionStatus, BlockStreamUtxo, Inscription, InscriptionFetchedData, RunesInfoByAddress, RunesInfoByAddressFetchedData } from '@subwallet/extension-base/services/chain-service/handler/bitcoin/strategy/types'; +import { HiroService } from '@subwallet/extension-base/services/hiro-service'; +import { RunesService } from '@subwallet/extension-base/services/rune-service'; +import { BaseApiRequestStrategy } from '@subwallet/extension-base/strategy/api-request-strategy'; +import { BaseApiRequestContext } from '@subwallet/extension-base/strategy/api-request-strategy/context/base'; +import { getRequest, postRequest } from '@subwallet/extension-base/strategy/api-request-strategy/utils'; +import { BitcoinFeeInfo, BitcoinTx, UtxoResponseItem } from '@subwallet/extension-base/types'; +import BigN from 'bignumber.js'; +import EventEmitter from 'eventemitter3'; + +export class BlockStreamTestnetRequestStrategy extends BaseApiRequestStrategy implements BitcoinApiStrategy { + private readonly baseUrl: string; + private readonly isTestnet: boolean; + private timePerBlock = 0; // in milliseconds + + constructor (url: string) { + const context = new BaseApiRequestContext(); + + super(context); + + this.baseUrl = url; + this.isTestnet = url.includes('testnet'); + } + + private headers = { + 'Content-Type': 'application/json' + }; + + isRateLimited (): boolean { + return false; + } + + getUrl (path: string): string { + return `${this.baseUrl}/${path}`; + } + + getBlockTime (): Promise { + return this.addRequest(async () => { + const response = await getRequest(this.getUrl('blocks'), undefined, this.headers); + const blocks = await response.json() as BlockStreamBlock[]; + + if (!response.ok) { + throw new SWError('BlockStreamTestnetRequestStrategy.getBlockTime', 'Failed to fetch blocks'); + } + + const length = blocks.length; + const sortedBlocks = blocks.sort((a, b) => b.timestamp - a.timestamp); + const time = (sortedBlocks[0].timestamp - sortedBlocks[length - 1].timestamp) * 1000; + + return time / length; + }, 0); + } + + async computeBlockTime (): Promise { + let blockTime = this.timePerBlock; + + if (blockTime > 0) { + return blockTime; + } + + try { + blockTime = await this.getBlockTime(); + + this.timePerBlock = blockTime; + } catch (e) { + console.error('Failed to compute block time', e); + + blockTime = (this.isTestnet ? 5 * 60 : 10 * 60) * 1000; // Default to 10 minutes if failed + } + + // Cache block time in 60 seconds + setTimeout(() => { + this.timePerBlock = 0; + }, 60000); + + return blockTime; + } + + getAddressSummaryInfo (address: string): Promise { + return this.addRequest(async () => { + const response = await getRequest(this.getUrl(`address/${address}`), undefined, this.headers); + + if (!response.ok) { + throw new SWError('BlockStreamTestnetRequestStrategy.getAddressSummaryInfo', 'Failed to fetch address info'); + } + + const rsRaw = await response.json() as BlockstreamAddressResponse; + const chainBalance = rsRaw.chain_stats.funded_txo_sum - rsRaw.chain_stats.spent_txo_sum; + const pendingLocked = rsRaw.mempool_stats.spent_txo_sum; // Only consider spent UTXOs in mempool + const mempoolReceived = rsRaw.mempool_stats.funded_txo_sum; // Funds received in mempool (e.g., change) + const availableBalance = Math.max(0, chainBalance - pendingLocked + mempoolReceived); // Ensure balance is non-negative + + const rs: BitcoinAddressSummaryInfo = { + address: rsRaw.address, + chain_stats: { + funded_txo_count: rsRaw.chain_stats.funded_txo_count, + funded_txo_sum: rsRaw.chain_stats.funded_txo_sum, + spent_txo_count: rsRaw.chain_stats.spent_txo_count, + spent_txo_sum: rsRaw.chain_stats.spent_txo_sum, + tx_count: rsRaw.chain_stats.tx_count + }, + mempool_stats: { + funded_txo_count: rsRaw.mempool_stats.funded_txo_count, + funded_txo_sum: rsRaw.mempool_stats.funded_txo_sum, + spent_txo_count: rsRaw.mempool_stats.spent_txo_count, + spent_txo_sum: rsRaw.mempool_stats.spent_txo_sum, + tx_count: rsRaw.mempool_stats.tx_count + }, + balance: availableBalance, + total_inscription: 0, + balance_rune: '0', + balance_inscription: '0' + }; + + return rs; + }, 0); + } + + getAddressTransaction (address: string, limit = 100): Promise { + return this.addRequest(async () => { + const response = await getRequest(this.getUrl(`address/${address}/txs`), { limit: `${limit}` }, this.headers); + + if (!response.ok) { + throw new SWError('BlockStreamTestnetRequestStrategy.getAddressTransaction', 'Failed to fetch transactions'); + } + + return await response.json() as BitcoinTx[]; + }, 1); + } + + getTransactionStatus (txHash: string): Promise { + return this.addRequest(async () => { + const response = await getRequest(this.getUrl(`tx/${txHash}/status`), undefined, {}); + + if (!response.ok) { + const errorText = await response.text(); + + throw new SWError('BlockStreamTestnetRequestStrategy.getTransactionStatus', `Failed to fetch transaction status: ${errorText}`); + } + + // Blockstream API trả về object thô + const data = await response.json() as BlockStreamTransactionStatus; + + return { + confirmed: data.confirmed || false, + block_time: data.block_time || 0, + block_height: data.block_height, + block_hash: data.block_hash + }; + }, 1); + } + + getTransactionDetail (txHash: string): Promise { + return this.addRequest(async () => { + const response = await getRequest(this.getUrl(`tx/${txHash}`), undefined, this.headers); + + if (!response.ok) { + throw new SWError('BlockStreamTestnetRequestStrategy.getTransactionDetail', 'Failed to fetch transaction detail'); + } + + return await response.json() as BlockStreamTransactionDetail; + }, 1); + } + + // TODO: NOTE: Currently not in use. Recheck the response if you want to use it. + async getFeeRate (): Promise { + const blockTime = await this.computeBlockTime(); + + return await this.addRequest(async (): Promise => { + const response = await getRequest(this.getUrl('fee-estimates'), undefined, this.headers); + const estimates = await response.json() as BlockStreamFeeEstimates; + + if (!response.ok) { + throw new SWError('BlockStreamTestnetRequestStrategy.getFeeRate', 'Failed to fetch fee estimates'); + } + + const low = 6; + const average = 3; + const fast = 1; + + const convertFee = (fee: number) => parseFloat(new BigN(fee).toFixed(2)); + + return { + type: 'bitcoin', + busyNetwork: false, + options: { + slow: { feeRate: convertFee(estimates[low] || 10), time: blockTime * low }, + average: { feeRate: convertFee(estimates[average || 12]), time: blockTime * average }, + fast: { feeRate: convertFee(estimates[fast] || 15), time: blockTime * fast }, + default: 'slow' + } + }; + }, 0); + } + + getRecommendedFeeRate (): Promise { + return this.addRequest(async (): Promise => { + const convertTimeMilisec = { + fastestFee: 10 * 60000, + halfHourFee: 30 * 60000, + hourFee: 60 * 60000 + }; + + const defaultFeeInfo: BitcoinFeeInfo = { + type: 'bitcoin', + busyNetwork: false, + options: { + slow: { feeRate: 1.5, time: convertTimeMilisec.hourFee }, + average: { feeRate: 1.5, time: convertTimeMilisec.halfHourFee }, + fast: { feeRate: 1.5, time: convertTimeMilisec.fastestFee }, + default: 'slow' + } + }; + + try { + const response = await getRequest(this.getUrl('/fee-estimates'), undefined, this.headers); + + if (!response.ok) { + console.warn(`Failed to fetch fee estimates: ${response.statusText}`); + + return defaultFeeInfo; + } + + const estimates = await response.json() as BlockStreamFeeEstimates; + + const convertFee = (fee: number) => { + const adjustedFee = parseInt(new BigN(fee).toFixed(), 10); + + return Math.max(adjustedFee, 1.5); + }; + + return { + type: 'bitcoin', + busyNetwork: false, + options: { + slow: { feeRate: convertFee(estimates['6'] || 1), time: this.timePerBlock * 6 }, // 6 block + average: { feeRate: convertFee(estimates['3'] || 1), time: this.timePerBlock * 3 }, // 3 block + fast: { feeRate: convertFee(estimates['1'] || 1), time: this.timePerBlock }, // 1 block + default: 'slow' + } + }; + } catch { + return defaultFeeInfo; + } + }, 0); + } + + getUtxos (address: string): Promise { + return this.addRequest(async (): Promise => { + const response = await getRequest(this.getUrl(`address/${address}/utxo`), undefined, {}); + const rs = await response.json() as BlockStreamUtxo[]; + + if (!response.ok) { + const errorText = await response.text(); + + throw new SWError('BlockStreamTestnetRequestStrategy.getUtxos', `Failed to fetch UTXOs: ${errorText}`); + } + + return rs.map((item: BlockStreamUtxo) => ({ + txid: item.txid, + vout: item.vout, + value: item.value, + status: item.status + })); + }, 0); + } + + sendRawTransaction (rawTransaction: string) { + const eventEmitter = new EventEmitter(); + + this.addRequest(async (): Promise => { + const response = await postRequest( + this.getUrl('tx'), + rawTransaction, + { 'Content-Type': 'text/plain' }, + false + ); + + if (!response.ok) { + const errorText = await response.text(); + + throw new SWError('BlockStreamTestnetRequestStrategy.sendRawTransaction', `Failed to broadcast transaction: ${errorText}`); + } + + return await response.text(); + }, 0) + .then((extrinsicHash) => { + eventEmitter.emit('extrinsicHash', extrinsicHash); + + // Check transaction status + const interval = setInterval(() => { + this.getTransactionStatus(extrinsicHash) + .then((transactionStatus) => { + if (transactionStatus.confirmed && transactionStatus.block_time > 0) { + clearInterval(interval); + eventEmitter.emit('success', transactionStatus); + } + }) + .catch(console.error); + }, 30000); + }) + .catch((error: Error) => { + eventEmitter.emit('error', error.message); + }) + ; + + return eventEmitter; + } + + simpleSendRawTransaction (rawTransaction: string) { + return this.addRequest(async (): Promise => { + const response = await postRequest(this.getUrl('tx'), rawTransaction, { 'Content-Type': 'text/plain' }, false); + + if (!response.ok) { + const errorText = await response.text(); + + throw new SWError('BlockStreamTestnetRequestStrategy.simpleSendRawTransaction', `Failed to broadcast transaction: ${errorText}`); + } + + return await response.text(); + }, 0); + } + + async getRunes (address: string) { + const runesFullList: RunesInfoByAddress[] = []; + const pageSize = 60; + let offset = 0; + + const runeService = RunesService.getInstance(this.isTestnet); + + try { + while (true) { + const response = await runeService.getAddressRunesInfo(address, { + limit: String(pageSize), + offset: String(offset) + }) as unknown as RunesInfoByAddressFetchedData; + + const runes = response.runes; + + if (runes.length !== 0) { + runesFullList.push(...runes); + offset += pageSize; + } else { + break; + } + } + + return runesFullList; + } catch (error) { + console.error(`Failed to get ${address} balances`, error); + throw error; + } + } + + async getRuneUtxos (address: string) { + const runeService = RunesService.getInstance(this.isTestnet); + + try { + const responseRuneUtxos = await runeService.getAddressRuneUtxos(address); + + return responseRuneUtxos.results; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + throw new SWError('BlockStreamTestnetRequestStrategy.getRuneUtxos', `Failed to get ${address} rune utxos: ${errorMessage}`); + } + } + + async getAddressInscriptions (address: string) { + const inscriptionsFullList: Inscription[] = []; + const pageSize = 60; + let offset = 0; + + const hiroService = HiroService.getInstance(this.isTestnet); + + try { + while (true) { + const response = await hiroService.getAddressInscriptionsInfo({ + limit: String(pageSize), + offset: String(offset), + address: String(address) + }) as unknown as InscriptionFetchedData; + + const inscriptions = response.results; + + if (inscriptions.length !== 0) { + inscriptionsFullList.push(...inscriptions); + offset += pageSize; + } else { + break; + } + } + + return inscriptionsFullList; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + throw new SWError('BlockStreamTestnetRequestStrategy.getAddressInscriptions', `Failed to get ${address} inscriptions: ${errorMessage}`); + } + } + + getTxHex (txHash: string): Promise { + return this.addRequest(async (): Promise => { + const response = await getRequest(this.getUrl(`tx/${txHash}/hex`), undefined, this.headers); + + if (!response.ok) { + const errorText = await response.text(); + + throw new SWError('BlockStreamTestnetRequestStrategy.getTxHex', `Failed to fetch transaction hex: ${errorText}`); + } + + return await response.text(); + }, 0); + } +} diff --git a/packages/extension-base/src/services/chain-service/handler/bitcoin/strategy/BlockStreamTestnet/index.ts b/packages/extension-base/src/services/chain-service/handler/bitcoin/strategy/BlockStreamTestnet/index.ts new file mode 100644 index 00000000000..8cfa4ff1851 --- /dev/null +++ b/packages/extension-base/src/services/chain-service/handler/bitcoin/strategy/BlockStreamTestnet/index.ts @@ -0,0 +1,5 @@ +// Copyright 2019-2022 @subwallet/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export { MempoolTestnetRequestStrategy } from './mempool-testnet-strategy'; +export { BlockStreamTestnetRequestStrategy } from './blockstream-testnet-strategy'; diff --git a/packages/extension-base/src/services/chain-service/handler/bitcoin/strategy/BlockStreamTestnet/mempool-testnet-strategy.ts b/packages/extension-base/src/services/chain-service/handler/bitcoin/strategy/BlockStreamTestnet/mempool-testnet-strategy.ts new file mode 100644 index 00000000000..7182a9f2eb7 --- /dev/null +++ b/packages/extension-base/src/services/chain-service/handler/bitcoin/strategy/BlockStreamTestnet/mempool-testnet-strategy.ts @@ -0,0 +1,419 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +import { SWError } from '@subwallet/extension-base/background/errors/SWError'; +import { BitcoinAddressSummaryInfo, BitcoinApiStrategy, BitcoinTransactionEventMap, BlockstreamAddressResponse, BlockStreamBlock, BlockStreamFeeEstimates, BlockStreamTransactionDetail, BlockStreamTransactionStatus, BlockStreamUtxo, Inscription, InscriptionFetchedData, RecommendedFeeEstimates, RunesInfoByAddress, RunesInfoByAddressFetchedData } from '@subwallet/extension-base/services/chain-service/handler/bitcoin/strategy/types'; +import { HiroService } from '@subwallet/extension-base/services/hiro-service'; +import { RunesService } from '@subwallet/extension-base/services/rune-service'; +import { BaseApiRequestStrategy } from '@subwallet/extension-base/strategy/api-request-strategy'; +import { BaseApiRequestContext } from '@subwallet/extension-base/strategy/api-request-strategy/context/base'; +import { getRequest, postRequest } from '@subwallet/extension-base/strategy/api-request-strategy/utils'; +import { BitcoinFeeInfo, BitcoinTx, UtxoResponseItem } from '@subwallet/extension-base/types'; +import BigN from 'bignumber.js'; +import EventEmitter from 'eventemitter3'; + +export class MempoolTestnetRequestStrategy extends BaseApiRequestStrategy implements BitcoinApiStrategy { + private readonly baseUrl: string; + private readonly isTestnet: boolean; + private timePerBlock = 0; // in milliseconds + + constructor (url: string) { + const context = new BaseApiRequestContext(); + + super(context); + + this.baseUrl = url; + this.isTestnet = url.includes('testnet'); + } + + private headers = { + 'Content-Type': 'application/json' + }; + + isRateLimited (): boolean { + return false; + } + + getUrl (path: string): string { + return `${this.baseUrl}/${path}`; + } + + getBlockTime (): Promise { + return this.addRequest(async () => { + const response = await getRequest(this.getUrl('blocks'), undefined, this.headers); + const blocks = await response.json() as BlockStreamBlock[]; + + if (!response.ok) { + throw new SWError('BlockStreamTestnetRequestStrategy.getBlockTime', 'Failed to fetch blocks'); + } + + const length = blocks.length; + const sortedBlocks = blocks.sort((a, b) => b.timestamp - a.timestamp); + const time = (sortedBlocks[0].timestamp - sortedBlocks[length - 1].timestamp) * 1000; + + return time / length; + }, 0); + } + + async computeBlockTime (): Promise { + let blockTime = this.timePerBlock; + + if (blockTime > 0) { + return blockTime; + } + + try { + blockTime = await this.getBlockTime(); + + this.timePerBlock = blockTime; + } catch (e) { + console.error('Failed to compute block time', e); + + blockTime = (this.isTestnet ? 5 * 60 : 10 * 60) * 1000; // Default to 10 minutes if failed + } + + // Cache block time in 60 seconds + setTimeout(() => { + this.timePerBlock = 0; + }, 60000); + + return blockTime; + } + + getAddressSummaryInfo (address: string): Promise { + return this.addRequest(async () => { + const response = await getRequest(this.getUrl(`address/${address}`), undefined, this.headers); + + if (!response.ok) { + throw new SWError('BlockStreamTestnetRequestStrategy.getAddressSummaryInfo', 'Failed to fetch address info'); + } + + const rsRaw = await response.json() as BlockstreamAddressResponse; + const chainBalance = rsRaw.chain_stats.funded_txo_sum - rsRaw.chain_stats.spent_txo_sum; + const pendingLocked = rsRaw.mempool_stats.spent_txo_sum; // Only consider spent UTXOs in mempool + const mempoolReceived = rsRaw.mempool_stats.funded_txo_sum; // Funds received in mempool (e.g., change) + const availableBalance = Math.max(0, chainBalance - pendingLocked + mempoolReceived); // Ensure balance is non-negative + + const rs: BitcoinAddressSummaryInfo = { + address: rsRaw.address, + chain_stats: { + funded_txo_count: rsRaw.chain_stats.funded_txo_count, + funded_txo_sum: rsRaw.chain_stats.funded_txo_sum, + spent_txo_count: rsRaw.chain_stats.spent_txo_count, + spent_txo_sum: rsRaw.chain_stats.spent_txo_sum, + tx_count: rsRaw.chain_stats.tx_count + }, + mempool_stats: { + funded_txo_count: rsRaw.mempool_stats.funded_txo_count, + funded_txo_sum: rsRaw.mempool_stats.funded_txo_sum, + spent_txo_count: rsRaw.mempool_stats.spent_txo_count, + spent_txo_sum: rsRaw.mempool_stats.spent_txo_sum, + tx_count: rsRaw.mempool_stats.tx_count + }, + balance: availableBalance, + total_inscription: 0, + balance_rune: '0', + balance_inscription: '0' + }; + + return rs; + }, 0); + } + + getAddressTransaction (address: string, limit = 100): Promise { + return this.addRequest(async () => { + const response = await getRequest(this.getUrl(`address/${address}/txs`), { limit: `${limit}` }, this.headers); + + if (!response.ok) { + throw new SWError('BlockStreamTestnetRequestStrategy.getAddressTransaction', 'Failed to fetch transactions'); + } + + return await response.json() as BitcoinTx[]; + }, 1); + } + + getTransactionStatus (txHash: string): Promise { + return this.addRequest(async () => { + const response = await getRequest(this.getUrl(`tx/${txHash}/status`), undefined, {}); + + if (!response.ok) { + const errorText = await response.text(); + + throw new SWError('BlockStreamTestnetRequestStrategy.getTransactionStatus', `Failed to fetch transaction status: ${errorText}`); + } + + // Blockstream API trả về object thô + const data = await response.json() as BlockStreamTransactionStatus; + + return { + confirmed: data.confirmed || false, + block_time: data.block_time || 0, + block_height: data.block_height, + block_hash: data.block_hash + }; + }, 1); + } + + getTransactionDetail (txHash: string): Promise { + return this.addRequest(async () => { + const response = await getRequest(this.getUrl(`tx/${txHash}`), undefined, this.headers); + + if (!response.ok) { + throw new SWError('BlockStreamTestnetRequestStrategy.getTransactionDetail', 'Failed to fetch transaction detail'); + } + + return await response.json() as BlockStreamTransactionDetail; + }, 1); + } + + // TODO: NOTE: Currently not in use. Recheck the response if you want to use it. + async getFeeRate (): Promise { + const blockTime = await this.computeBlockTime(); + + return await this.addRequest(async (): Promise => { + const response = await getRequest(this.getUrl('fee-estimates'), undefined, this.headers); + const estimates = await response.json() as BlockStreamFeeEstimates; + + if (!response.ok) { + throw new SWError('BlockStreamTestnetRequestStrategy.getFeeRate', 'Failed to fetch fee estimates'); + } + + const low = 6; + const average = 3; + const fast = 1; + + const convertFee = (fee: number) => parseFloat(new BigN(fee).toFixed(2)); + + return { + type: 'bitcoin', + busyNetwork: false, + options: { + slow: { feeRate: convertFee(estimates[low] || 10), time: blockTime * low }, + average: { feeRate: convertFee(estimates[average || 12]), time: blockTime * average }, + fast: { feeRate: convertFee(estimates[fast] || 15), time: blockTime * fast }, + default: 'slow' + } + }; + }, 0); + } + + getRecommendedFeeRate (): Promise { + return this.addRequest(async (): Promise => { + const convertTimeMilisec = { + fastestFee: 10 * 60000, + halfHourFee: 30 * 60000, + hourFee: 60 * 60000 + }; + + const defaultFeeInfo: BitcoinFeeInfo = { + type: 'bitcoin', + busyNetwork: false, + options: { + slow: { feeRate: 1.5, time: convertTimeMilisec.hourFee }, + average: { feeRate: 1.5, time: convertTimeMilisec.halfHourFee }, + fast: { feeRate: 1.5, time: convertTimeMilisec.fastestFee }, + default: 'slow' + } + }; + + try { + const response = await getRequest(this.getUrl('v1/fees/recommended'), undefined, this.headers); + + if (!response.ok) { + console.warn(`Failed to fetch fee estimates: ${response.statusText}`); + + return defaultFeeInfo; + } + + const estimates = await response.json() as RecommendedFeeEstimates; + + const convertFee = (fee: number) => { + const adjustedFee = parseInt(new BigN(fee).toFixed(), 10); + + return Math.max(adjustedFee, 1.5); + }; + + return { + type: 'bitcoin', + busyNetwork: false, + options: { + slow: { feeRate: convertFee(estimates.hourFee || 1), time: convertTimeMilisec.hourFee }, + average: { feeRate: convertFee(estimates.halfHourFee || 1), time: convertTimeMilisec.halfHourFee }, + fast: { feeRate: convertFee(estimates.fastestFee || 1), time: convertTimeMilisec.fastestFee }, + default: 'slow' + } + }; + } catch { + return defaultFeeInfo; + } + }, 0); + } + + getUtxos (address: string): Promise { + return this.addRequest(async (): Promise => { + const response = await getRequest(this.getUrl(`address/${address}/utxo`), undefined, {}); + const rs = await response.json() as BlockStreamUtxo[]; + + if (!response.ok) { + const errorText = await response.text(); + + throw new SWError('BlockStreamTestnetRequestStrategy.getUtxos', `Failed to fetch UTXOs: ${errorText}`); + } + + return rs.map((item: BlockStreamUtxo) => ({ + txid: item.txid, + vout: item.vout, + value: item.value, + status: item.status + })); + }, 0); + } + + sendRawTransaction (rawTransaction: string) { + const eventEmitter = new EventEmitter(); + + this.addRequest(async (): Promise => { + const response = await postRequest( + this.getUrl('tx'), + rawTransaction, + { 'Content-Type': 'text/plain' }, + false + ); + + if (!response.ok) { + const errorText = await response.text(); + + throw new SWError('BlockStreamTestnetRequestStrategy.sendRawTransaction', `Failed to broadcast transaction: ${errorText}`); + } + + return await response.text(); + }, 0) + .then((extrinsicHash) => { + eventEmitter.emit('extrinsicHash', extrinsicHash); + + // Check transaction status + const interval = setInterval(() => { + this.getTransactionStatus(extrinsicHash) + .then((transactionStatus) => { + if (transactionStatus.confirmed && transactionStatus.block_time > 0) { + clearInterval(interval); + eventEmitter.emit('success', transactionStatus); + } + }) + .catch(console.error); + }, 30000); + }) + .catch((error: Error) => { + eventEmitter.emit('error', error.message); + }) + ; + + return eventEmitter; + } + + simpleSendRawTransaction (rawTransaction: string) { + return this.addRequest(async (): Promise => { + const response = await postRequest(this.getUrl('tx'), rawTransaction, { 'Content-Type': 'text/plain' }, false); + + if (!response.ok) { + const errorText = await response.text(); + + throw new SWError('BlockStreamTestnetRequestStrategy.simpleSendRawTransaction', `Failed to broadcast transaction: ${errorText}`); + } + + return await response.text(); + }, 0); + } + + async getRunes (address: string) { + const runesFullList: RunesInfoByAddress[] = []; + const pageSize = 60; + let offset = 0; + + const runeService = RunesService.getInstance(this.isTestnet); + + try { + while (true) { + const response = await runeService.getAddressRunesInfo(address, { + limit: String(pageSize), + offset: String(offset) + }) as unknown as RunesInfoByAddressFetchedData; + + const runes = response.runes; + + if (runes.length !== 0) { + runesFullList.push(...runes); + offset += pageSize; + } else { + break; + } + } + + return runesFullList; + } catch (error) { + console.error(`Failed to get ${address} balances`, error); + throw error; + } + } + + async getRuneUtxos (address: string) { + const runeService = RunesService.getInstance(this.isTestnet); + + try { + const responseRuneUtxos = await runeService.getAddressRuneUtxos(address); + + return responseRuneUtxos.results; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + throw new SWError('BlockStreamTestnetRequestStrategy.getRuneUtxos', `Failed to get ${address} rune utxos: ${errorMessage}`); + } + } + + async getAddressInscriptions (address: string) { + const inscriptionsFullList: Inscription[] = []; + const pageSize = 60; + let offset = 0; + + const hiroService = HiroService.getInstance(this.isTestnet); + + try { + while (true) { + const response = await hiroService.getAddressInscriptionsInfo({ + limit: String(pageSize), + offset: String(offset), + address: String(address) + }) as unknown as InscriptionFetchedData; + + const inscriptions = response.results; + + if (inscriptions.length !== 0) { + inscriptionsFullList.push(...inscriptions); + offset += pageSize; + } else { + break; + } + } + + return inscriptionsFullList; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + throw new SWError('BlockStreamTestnetRequestStrategy.getAddressInscriptions', `Failed to get ${address} inscriptions: ${errorMessage}`); + } + } + + getTxHex (txHash: string): Promise { + return this.addRequest(async (): Promise => { + const response = await getRequest(this.getUrl(`tx/${txHash}/hex`), undefined, this.headers); + + if (!response.ok) { + const errorText = await response.text(); + + throw new SWError('BlockStreamTestnetRequestStrategy.getTxHex', `Failed to fetch transaction hex: ${errorText}`); + } + + return await response.text(); + }, 0); + } +} diff --git a/packages/extension-base/src/services/chain-service/handler/bitcoin/strategy/SubWalletMainnet/index.ts b/packages/extension-base/src/services/chain-service/handler/bitcoin/strategy/SubWalletMainnet/index.ts new file mode 100644 index 00000000000..2e3805107ee --- /dev/null +++ b/packages/extension-base/src/services/chain-service/handler/bitcoin/strategy/SubWalletMainnet/index.ts @@ -0,0 +1,353 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +import { SWError } from '@subwallet/extension-base/background/errors/SWError'; +import { _BTC_SERVICE_TOKEN } from '@subwallet/extension-base/services/chain-service/constants'; +import { BitcoinAddressSummaryInfo, BitcoinApiStrategy, BitcoinTransactionEventMap, BlockStreamBlock, BlockStreamFeeEstimates, BlockStreamTransactionDetail, BlockStreamTransactionStatus, Inscription, InscriptionFetchedData, RecommendedFeeEstimates, RunesInfoByAddress, RunesInfoByAddressFetchedData, UpdateOpenBitUtxo } from '@subwallet/extension-base/services/chain-service/handler/bitcoin/strategy/types'; +import { OBResponse } from '@subwallet/extension-base/services/chain-service/types'; +import { HiroService } from '@subwallet/extension-base/services/hiro-service'; +import { RunesService } from '@subwallet/extension-base/services/rune-service'; +import { BaseApiRequestStrategy } from '@subwallet/extension-base/strategy/api-request-strategy'; +import { BaseApiRequestContext } from '@subwallet/extension-base/strategy/api-request-strategy/context/base'; +import { getRequest, postRequest } from '@subwallet/extension-base/strategy/api-request-strategy/utils'; +import { BitcoinFeeInfo, BitcoinTx, UtxoResponseItem } from '@subwallet/extension-base/types'; +import BigN from 'bignumber.js'; +import EventEmitter from 'eventemitter3'; + +export class SubWalletMainnetRequestStrategy extends BaseApiRequestStrategy implements BitcoinApiStrategy { + private readonly baseUrl: string; + private readonly isTestnet: boolean; + private timePerBlock = 0; // in milliseconds + + constructor (url: string) { + const context = new BaseApiRequestContext(); + + super(context); + + this.baseUrl = url; + this.isTestnet = url.includes('testnet'); + } + + private headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${_BTC_SERVICE_TOKEN}` + }; + + isRateLimited (): boolean { + return false; + } + + getUrl (path: string): string { + return `${this.baseUrl}/${path}`; + } + + getBlockTime (): Promise { + return this.addRequest(async () => { + const _rs = await getRequest(this.getUrl('blocks'), undefined, this.headers); + const rs = await _rs.json() as OBResponse; + + if (rs.status_code !== 200) { + throw new SWError('BlockStreamRequestStrategy.getBlockTime', rs.message); + } + + const blocks = rs.result; + const length = blocks.length; + const sortedBlocks = blocks.sort((a, b) => b.timestamp - a.timestamp); + const time = (sortedBlocks[0].timestamp - sortedBlocks[length - 1].timestamp) * 1000; + + return time / length; + }, 0); + } + + async computeBlockTime (): Promise { + let blockTime = this.timePerBlock; + + if (blockTime > 0) { + return blockTime; + } + + try { + blockTime = await this.getBlockTime(); + + this.timePerBlock = blockTime; + } catch (e) { + console.error('Failed to compute block time', e); + + blockTime = (this.isTestnet ? 5 * 60 : 10 * 60) * 1000; // Default to 10 minutes if failed + } + + // Cache block time in 60 seconds + setTimeout(() => { + this.timePerBlock = 0; + }, 60000); + + return blockTime; + } + + getAddressSummaryInfo (address: string): Promise { + return this.addRequest(async () => { + const _rs = await getRequest(this.getUrl(`address/${address}`), undefined, this.headers); + const rs = await _rs.json() as OBResponse; + + if (rs.status_code !== 200) { + throw new SWError('BlockStreamRequestStrategy.getAddressSummaryInfo', rs.message); + } + + return rs.result; + }, 0); + } + + getAddressTransaction (address: string, limit = 100): Promise { + return this.addRequest(async () => { + const _rs = await getRequest(this.getUrl(`address/${address}/txs`), { limit: `${limit}` }, this.headers); + const rs = await _rs.json() as OBResponse; + + if (rs.status_code !== 200) { + throw new SWError('BlockStreamRequestStrategy.getAddressTransaction', rs.message); + } + + return rs.result; + }, 1); + } + + getTransactionStatus (txHash: string): Promise { + return this.addRequest(async () => { + const _rs = await getRequest(this.getUrl(`tx/${txHash}/status`), undefined, this.headers); + const rs = await _rs.json() as OBResponse; + + if (rs.status_code !== 200) { + throw new SWError('BlockStreamRequestStrategy.getTransactionStatus', rs.message); + } + + return rs.result; + }, 1); + } + + getTransactionDetail (txHash: string): Promise { + return this.addRequest(async () => { + const _rs = await getRequest(this.getUrl(`tx/${txHash}`), undefined, this.headers); + const rs = await _rs.json() as OBResponse; + + if (rs.status_code !== 200) { + throw new SWError('BlockStreamRequestStrategy.getTransactionDetail', rs.message); + } + + return rs.result; + }, 1); + } + + async getFeeRate (): Promise { + const timePerBlock = await this.computeBlockTime(); + + return await this.addRequest(async (): Promise => { + const _rs = await getRequest(this.getUrl('fee-estimates'), undefined, this.headers); + const rs = await _rs.json() as OBResponse; + + if (rs.status_code !== 200) { + throw new SWError('BlockStreamRequestStrategy.getFeeRate', rs.message); + } + + const result = rs.result; + + const low = 6; + const average = 3; + const fast = 1; + + const convertFee = (fee: number) => parseFloat(new BigN(fee).toFixed(2)); + + return { + type: 'bitcoin', + busyNetwork: false, + options: { + slow: { feeRate: convertFee(result[low]), time: timePerBlock * low }, + average: { feeRate: convertFee(result[average]), time: timePerBlock * average }, + fast: { feeRate: convertFee(result[fast]), time: timePerBlock * fast }, + default: 'slow' + } + }; + }, 0); + } + + getRecommendedFeeRate (): Promise { + return this.addRequest(async (): Promise => { + const _rs = await getRequest(this.getUrl('fee-estimates/recommended'), undefined, this.headers); + const rs = await _rs.json() as OBResponse; + + if (rs.status_code !== 200) { + throw new SWError('BlockStreamRequestStrategy.getRecommendedFeeRate', rs.message); + } + + const result = rs.result; + + const convertTimeMilisec = { + fastestFee: 10 * 60000, + halfHourFee: 30 * 60000, + hourFee: 60 * 60000 + }; + + const convertFee = (fee: number) => parseInt(new BigN(fee).toFixed()); + + return { + type: 'bitcoin', + busyNetwork: false, + options: { + slow: { feeRate: convertFee(result.hourFee), time: convertTimeMilisec.hourFee }, + average: { feeRate: convertFee(result.halfHourFee), time: convertTimeMilisec.halfHourFee }, + fast: { feeRate: convertFee(result.fastestFee), time: convertTimeMilisec.fastestFee }, + default: 'slow' + } + }; + }, 0); + } + + getUtxos (address: string): Promise { + return this.addRequest(async (): Promise => { + const _rs = await getRequest(this.getUrl(`address/${address}/utxo`), undefined, this.headers); + const rs = await _rs.json() as OBResponse; + + if (rs.status_code !== 200) { + throw new SWError('BlockStreamRequestStrategy.getUtxos', rs.message); + } + + return rs.result.utxoItems; + }, 0); + } + + sendRawTransaction (rawTransaction: string) { + const eventEmitter = new EventEmitter(); + + this.addRequest(async (): Promise => { + const _rs = await postRequest(this.getUrl('tx'), rawTransaction, this.headers, false); + const rs = await _rs.json() as OBResponse; + + if (rs.status_code !== 200) { + throw new SWError('BlockStreamRequestStrategy.sendRawTransaction', rs.message); + } + + return rs.result; + }, 0) + .then((extrinsicHash) => { + eventEmitter.emit('extrinsicHash', extrinsicHash); + + // Check transaction status + const interval = setInterval(() => { + this.getTransactionStatus(extrinsicHash) + .then((transactionStatus) => { + if (transactionStatus.confirmed && transactionStatus.block_time > 0) { + clearInterval(interval); + eventEmitter.emit('success', transactionStatus); + } + }) + .catch(console.error); + }, 30000); + }) + .catch((error: Error) => { + eventEmitter.emit('error', error.message); + }) + ; + + return eventEmitter; + } + + simpleSendRawTransaction (rawTransaction: string) { + return this.addRequest(async (): Promise => { + const _rs = await postRequest(this.getUrl('tx'), rawTransaction, this.headers, false); + const rs = await _rs.json() as OBResponse; + + if (rs.status_code !== 200) { + throw new SWError('BlockStreamRequestStrategy.simpleSendRawTransaction', rs.message); + } + + return rs.result; + }, 0); + } + + async getRunes (address: string) { + const runesFullList: RunesInfoByAddress[] = []; + const pageSize = 60; + let offset = 0; + + const runeService = RunesService.getInstance(this.isTestnet); + + try { + while (true) { + const response = await runeService.getAddressRunesInfo(address, { + limit: String(pageSize), + offset: String(offset) + }) as unknown as RunesInfoByAddressFetchedData; + + const runes = response.runes; + + if (runes.length !== 0) { + runesFullList.push(...runes); + offset += pageSize; + } else { + break; + } + } + + return runesFullList; + } catch (error) { + console.error(`Failed to get ${address} balances`, error); + throw error; + } + } + + async getRuneUtxos (address: string) { + const runeService = RunesService.getInstance(this.isTestnet); + + try { + const responseRuneUtxos = await runeService.getAddressRuneUtxos(address); + + return responseRuneUtxos.results; + } catch (error) { + console.error(`Failed to get ${address} rune utxos`, error); + throw error; + } + } + + async getAddressInscriptions (address: string) { + const inscriptionsFullList: Inscription[] = []; + const pageSize = 60; + let offset = 0; + + const hiroService = HiroService.getInstance(this.isTestnet); + + try { + while (true) { + const response = await hiroService.getAddressInscriptionsInfo({ + limit: String(pageSize), + offset: String(offset), + address: String(address) + }) as unknown as InscriptionFetchedData; + + const inscriptions = response.results; + + if (inscriptions.length !== 0) { + inscriptionsFullList.push(...inscriptions); + offset += pageSize; + } else { + break; + } + } + + return inscriptionsFullList; + } catch (error) { + console.error(`Failed to get ${address} inscriptions`, error); + throw error; + } + } + + getTxHex (txHash: string): Promise { + return this.addRequest(async (): Promise => { + const _rs = await getRequest(this.getUrl(`tx/${txHash}/hex`), undefined, this.headers); + const rs = await _rs.json() as OBResponse; + + if (rs.status_code !== 200) { + throw new SWError('BlockStreamRequestStrategy.getTxHex', rs.message); + } + + return rs.result; + }, 0); + } +} diff --git a/packages/extension-base/src/services/chain-service/handler/bitcoin/strategy/types.ts b/packages/extension-base/src/services/chain-service/handler/bitcoin/strategy/types.ts new file mode 100644 index 00000000000..367fc0bfeff --- /dev/null +++ b/packages/extension-base/src/services/chain-service/handler/bitcoin/strategy/types.ts @@ -0,0 +1,336 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +import { ApiRequestStrategy } from '@subwallet/extension-base/strategy/api-request-strategy/types'; +import { BitcoinFeeInfo, BitcoinTransactionStatus, BitcoinTx, UtxoResponseItem } from '@subwallet/extension-base/types'; +import EventEmitter from 'eventemitter3'; + +export interface BitcoinApiStrategy extends Omit { + getBlockTime (): Promise; + computeBlockTime (): Promise; + getAddressSummaryInfo (address: string): Promise; + getRunes (address: string): Promise; + // getRuneTxsUtxos (address: string): Promise; // noted: all rune utxos come in account + getRuneUtxos (address: string): Promise; + // getAddressBRC20FreeLockedBalance (address: string, ticker: string): Promise; + getAddressInscriptions (address: string): Promise + getAddressTransaction (address: string, limit?: number): Promise; + getTransactionStatus (txHash: string): Promise; + getTransactionDetail (txHash: string): Promise; + getFeeRate (): Promise; + getRecommendedFeeRate (): Promise; + getUtxos (address: string): Promise; + getTxHex (txHash: string): Promise; + sendRawTransaction (rawTransaction: string): EventEmitter; + simpleSendRawTransaction (rawTransaction: string): Promise; +} + +export interface BitcoinTransactionEventMap { + extrinsicHash: (txHash: string) => void; + error: (error: string) => void; + success: (data: BitcoinTransactionStatus) => void; +} + +export interface BlockStreamBlock { + id: string; + height: number; + version: number; + timestamp: number; + tx_count: number; + size: number; + weight: number; + merkle_root: string; + previousblockhash: string; + mediantime: number; + nonce: number; + bits: number; + difficulty: number; +} + +export interface BlockstreamAddressResponse { + address: string; + chain_stats: { + funded_txo_count: number; + funded_txo_sum: number; + spent_txo_count: number; + spent_txo_sum: number; + tx_count: number; + }; + mempool_stats: { + funded_txo_count: number; + funded_txo_sum: number; + spent_txo_count: number; + spent_txo_sum: number; + tx_count: number; + }; +} + +export interface BitcoinAddressSummaryInfo extends BlockstreamAddressResponse{ + balance: number, + total_inscription: number, + balance_rune: string, + balance_inscription: string, +} + +// todo: combine RunesByAddressResponse & RunesCollectionInfoResponse + +export interface RunesInfoByAddressResponse { + statusCode: number, + data: RunesInfoByAddressFetchedData +} + +export interface RunesInfoByAddressFetchedData { + limit: number, + offset: number, + total: number, + runes: RunesInfoByAddress[] +} + +// todo: check is_hot and turbo and cenotaph attributes meaning in RuneInfoByAddress + +export interface RunesInfoByAddress { + amount: string, + address: string, + rune_id: string, + rune: { + rune: string, + rune_name: string, + divisibility: number, + premine: string, + spacers: string, + symbol: string + } +} + +export interface RunesCollectionInfoResponse { + statusCode: number, + data: RunesCollectionInfoFetchedData +} + +interface RunesCollectionInfoFetchedData { + limit: number, + offset: number, + total: number, + runes: RunesCollectionInfo[] +} + +export interface RunesCollectionInfo { + rune_id: string, + rune: string, + rune_name: string, + divisibility: string, + spacers: string +} + +export interface RuneTxsResponse { + statusCode: number, + data: RuneTxsFetchedData +} + +interface RuneTxsFetchedData { + limit: number, + offset: number, + total: number, + transactions: RuneTxs[] +} + +export interface RuneTxs { + txid: string, + vout: RuneTxsUtxosVout[] +} + +interface RuneTxsUtxosVout { + n: number, + value: number, + runeInject: any +} + +export interface Brc20MetadataFetchedData { + token: Brc20Metadata +} + +export interface Brc20Metadata { + ticker: string, + decimals: number +} + +export interface Brc20BalanceFetchedData { + limit: number, + offset: number, + total: number, + results: Brc20Balance[] +} + +export interface Brc20Balance { + ticker: string, + available_balance: string, + transferrable_balance: string, + overall_balance: string +} + +export interface Brc20BalanceItem { + free: string, + locked: string +} + +export interface InscriptionFetchedData { + limit: number, + offset: number, + total: number, + results: Inscription[] +} + +export interface Inscription { + id: string; + number: number; + address: string; + genesis_block_height: number; + genesis_block_hash: string; + genesis_timestamp: number; + tx_id: string; + location: string; + output: string; + value: string; + offset: string; + fee: number; + sat_ordinal: string; + sat_rarity: string; + content_type: string; + content_length: number; + // content: any +} + +export interface UpdateOpenBitUtxo { + totalUtxo: number, + utxoItems: BlockStreamUtxo[] +} + +export interface BlockStreamUtxo { + txid: string; + vout: number; + status: { + confirmed: boolean; + block_height?: number; + block_hash: string; + block_time?: number; + }, + value: number; +} + +export interface BlockStreamTransactionStatus { + confirmed: boolean; + block_height: number; + block_hash: string; + block_time: number; +} + +export interface BlockStreamFeeEstimates { + 1: number; + 2: number; + 3: number; + 4: number; + 5: number; + 6: number; + 7: number; + 8: number; +} + +export interface RecommendedFeeEstimates { + fastestFee: number, + halfHourFee: number, + hourFee: number, + economyFee: number, + minimumFee: number +} + +export interface BlockStreamTransactionVectorOutput { + scriptpubkey: string; + scriptpubkey_asm: string; + scriptpubkey_type: string; + scriptpubkey_address: string; + value: number; +} + +export interface BlockStreamTransactionVectorInput { + is_coinbase: boolean; + prevout: BlockStreamTransactionVectorOutput; + scriptsig: string; + scriptsig_asm: string; + sequence: number; + txid: string; + vout: number; + witness: string[]; +} + +export interface BlockStreamTransactionDetail { + txid: string; + version: number; + locktime: number; + totalVin: number; + totalVout: number; + size: number; + weight: number; + fee: number; + status: { + confirmed: boolean; + block_height?: number; + block_hash?: string; + block_time?: number; + } + vin: BlockStreamTransactionVectorInput[]; + vout: BlockStreamTransactionVectorOutput[]; +} + +export interface RuneUtxoResponse { + total: number, + results: RuneUtxo[] +} + +export interface RuneUtxo { + height: number, + confirmations: number, + address: string, + satoshi: number, + scriptPk: string, + txid: string, + vout: number, + runes: RuneInject[] +} + +interface RuneInject { + rune: string, + runeid: string, + spacedRune: string, + amount: string, + symbol: string, + divisibility: number +} + +export interface RuneMetadata { + id: string, + mintable: boolean, + parent: string, + entry: RuneInfo +} + +interface RuneInfo { + block: number, + burned: string, + divisibility: number, + etching: string, + mints: string, + number: number, + premine: string, + spaced_rune: string, + symbol: string, + terms: RuneTerms + timestamp: string, + turbo: boolean +} + +interface RuneTerms { + amount: string, + cap: string, + height: string[], + offset: string[] +} diff --git a/packages/extension-base/src/services/chain-service/index.ts b/packages/extension-base/src/services/chain-service/index.ts index 76788df687f..70c5c53ed00 100644 --- a/packages/extension-base/src/services/chain-service/index.ts +++ b/packages/extension-base/src/services/chain-service/index.ts @@ -7,6 +7,7 @@ import { _AssetRef, _AssetRefPath, _AssetType, _CardanoInfo, _ChainAsset, _Chain import { AssetSetting, CardanoPaginate, MetadataItem, SufficientChainsDetails, TokenPriorityDetails, ValidateNetworkResponse } from '@subwallet/extension-base/background/KoniTypes'; import { CardanoUtxosItem } from '@subwallet/extension-base/services/balance-service/helpers/subscribe/cardano/types'; import { _DEFAULT_ACTIVE_CHAINS, _ZK_ASSET_PREFIX, LATEST_CHAIN_DATA_FETCHING_INTERVAL } from '@subwallet/extension-base/services/chain-service/constants'; +import { BitcoinChainHandler } from '@subwallet/extension-base/services/chain-service/handler/bitcoin/BitcoinChainHandler'; import { CardanoChainHandler } from '@subwallet/extension-base/services/chain-service/handler/CardanoChainHandler'; import { EvmChainHandler } from '@subwallet/extension-base/services/chain-service/handler/EvmChainHandler'; import { MantaPrivateHandler } from '@subwallet/extension-base/services/chain-service/handler/manta/MantaPrivateHandler'; @@ -14,7 +15,7 @@ import { SubstrateChainHandler } from '@subwallet/extension-base/services/chain- import { TonChainHandler } from '@subwallet/extension-base/services/chain-service/handler/TonChainHandler'; import { _CHAIN_VALIDATION_ERROR } from '@subwallet/extension-base/services/chain-service/handler/types'; import { _ChainApiStatus, _ChainConnectionStatus, _ChainState, _CUSTOM_PREFIX, _DataMap, _EvmApi, _NetworkUpsertParams, _NFT_CONTRACT_STANDARDS, _SMART_CONTRACT_STANDARDS, _SmartContractTokenInfo, _SubstrateApi, _ValidateCustomAssetRequest, _ValidateCustomAssetResponse } from '@subwallet/extension-base/services/chain-service/types'; -import { _getAssetOriginChain, _getTokenOnChainAssetId, _isAssetAutoEnable, _isAssetCanPayTxFee, _isAssetFungibleToken, _isChainEnabled, _isCustomAsset, _isCustomChain, _isCustomProvider, _isEqualContractAddress, _isEqualSmartContractAsset, _isLocalToken, _isMantaZkAsset, _isNativeToken, _isPureEvmChain, _isPureSubstrateChain, _parseAssetRefKey, randomizeProvider, updateLatestChainInfo } from '@subwallet/extension-base/services/chain-service/utils'; +import { _getAssetOriginChain, _getTokenOnChainAssetId, _isAssetAutoEnable, _isAssetCanPayTxFee, _isAssetFungibleToken, _isChainBitcoinCompatible, _isChainEnabled, _isCustomAsset, _isCustomChain, _isCustomProvider, _isEqualContractAddress, _isEqualSmartContractAsset, _isLocalToken, _isMantaZkAsset, _isNativeToken, _isPureBitcoinChain, _isPureEvmChain, _isPureSubstrateChain, _parseAssetRefKey, randomizeProvider, updateLatestChainInfo } from '@subwallet/extension-base/services/chain-service/utils'; import { EventService } from '@subwallet/extension-base/services/event-service'; import { MYTHOS_MIGRATION_KEY } from '@subwallet/extension-base/services/migration-service/scripts'; import { convertUtxoRawToUtxo } from '@subwallet/extension-base/services/request-service/helper'; @@ -33,9 +34,10 @@ import { ExtraInfo } from '@polkadot-api/merkleize-metadata'; const filterChainInfoMap = (data: Record, ignoredChains: string[]): Record => { return Object.fromEntries( Object.entries(data) - .filter(([slug, info]) => !info.bitcoinInfo && !ignoredChains.includes(slug)) + .filter(([slug, info]) => !ignoredChains.includes(slug)) ); }; +// .filter(([slug, info]) => !info.bitcoinInfo && !ignoredChains.includes(slug)) const ignoredList = [ 'bevm', @@ -59,7 +61,15 @@ const ignoredList = [ export const filterAssetInfoMap = (chainInfo: Record, assets: Record, addedChains?: string[]): Record => { return Object.fromEntries( Object.entries(assets) - .filter(([, info]) => chainInfo[info.originChain] || addedChains?.includes(info.originChain)) + .filter(([, info]) => { + const isBitcoinChain = chainInfo?.[info.originChain] && _isChainBitcoinCompatible(chainInfo[info.originChain]); + + if (isBitcoinChain) { + return ![_AssetType.RUNE, _AssetType.BRC20].includes(info.assetType); + } + + return chainInfo[info.originChain] || addedChains?.includes(info.originChain); + }) ); }; @@ -78,6 +88,7 @@ export class ChainService { private substrateChainHandler: SubstrateChainHandler; private evmChainHandler: EvmChainHandler; + private bitcoinChainHandler: BitcoinChainHandler; private tonChainHandler: TonChainHandler; private cardanoChainHandler: CardanoChainHandler; private mantaChainHandler: MantaPrivateHandler | undefined; @@ -127,6 +138,7 @@ export class ChainService { this.evmChainHandler = new EvmChainHandler(this); this.tonChainHandler = new TonChainHandler(this); this.cardanoChainHandler = new CardanoChainHandler(this); + this.bitcoinChainHandler = new BitcoinChainHandler(this); this.logger = createLogger('chain-service'); } @@ -220,6 +232,14 @@ export class ChainService { return this.substrateChainHandler.getSubstrateApiMap(); } + public getBitcoinApi (slug: string) { + return this.bitcoinChainHandler.getApiByChain(slug); + } + + public getBitcoinApiMap () { + return this.bitcoinChainHandler.getApiMap(); + } + public getTonApi (slug: string) { return this.tonChainHandler.getTonApiByChain(slug); } @@ -1048,6 +1068,12 @@ export class ChainService { this.cardanoChainHandler.setCardanoApi(chainInfo.slug, chainApi); } + + if (chainInfo.bitcoinInfo !== null && chainInfo.bitcoinInfo !== undefined) { + const chainApi = await this.bitcoinChainHandler.initApi(chainInfo.slug, endpoint, { providerName, onUpdateStatus }); + + this.bitcoinChainHandler.setApi(chainInfo.slug, chainApi); + } } private destroyApiForChain (chainInfo: _ChainInfo) { @@ -1066,6 +1092,10 @@ export class ChainService { if (chainInfo.cardanoInfo !== null) { this.cardanoChainHandler.destroyCardanoApi(chainInfo.slug); } + + if (chainInfo.bitcoinInfo !== null && chainInfo.bitcoinInfo !== undefined) { + this.bitcoinChainHandler.destroyApi(chainInfo.slug); + } } public async enableChain (chainSlug: string) { @@ -2033,7 +2063,8 @@ export class ChainService { this.substrateChainHandler.sleep(), this.evmChainHandler.sleep(), this.tonChainHandler.sleep(), - this.cardanoChainHandler.sleep() + this.cardanoChainHandler.sleep(), + this.bitcoinChainHandler.sleep() ]); this.stopCheckLatestChainData(); @@ -2044,7 +2075,8 @@ export class ChainService { this.substrateChainHandler.wakeUp(), this.evmChainHandler.wakeUp(), this.tonChainHandler.wakeUp(), - this.cardanoChainHandler.wakeUp() + this.cardanoChainHandler.wakeUp(), + this.bitcoinChainHandler.wakeUp() ]); this.checkLatestData(); diff --git a/packages/extension-base/src/services/chain-service/types.ts b/packages/extension-base/src/services/chain-service/types.ts index cade2a71559..9ea6755edb9 100644 --- a/packages/extension-base/src/services/chain-service/types.ts +++ b/packages/extension-base/src/services/chain-service/types.ts @@ -8,6 +8,7 @@ import type { ApiInterfaceRx } from '@polkadot/api/types'; import { _AssetRef, _AssetType, _ChainAsset, _ChainInfo, _CrowdloanFund } from '@subwallet/chain-list/types'; import { CardanoBalanceItem } from '@subwallet/extension-base/services/balance-service/helpers/subscribe/cardano/types'; import { AccountState, TxByMsgResponse } from '@subwallet/extension-base/services/balance-service/helpers/subscribe/ton/types'; +import { BitcoinApiStrategy } from '@subwallet/extension-base/services/chain-service/handler/bitcoin/strategy/types'; import { _CHAIN_VALIDATION_ERROR } from '@subwallet/extension-base/services/chain-service/handler/types'; import { TonWalletContract } from '@subwallet/keyring/types'; import { Cell } from '@ton/core'; @@ -239,3 +240,26 @@ export const _NFT_CONTRACT_STANDARDS = [ ]; export const _SMART_CONTRACT_STANDARDS = [..._FUNGIBLE_CONTRACT_STANDARDS, ..._NFT_CONTRACT_STANDARDS]; + +export interface BitcoinApiProxy { + setBaseUrl: (baseUrl: string) => void, + getRequest: (urlPath: string, params?: Record, headers?: Record) => Promise, + postRequest: (urlPath: string, body?: BodyInit, headers?: Record) => Promise +} + +export interface _BitcoinApi extends _ChainBaseApi { + isReady: Promise<_BitcoinApi>; + api: BitcoinApiStrategy; +} + +export interface OBResponse { + status_code: number, + message: string, + result: T, +} + +export interface OBRuneResponse { + status_code: number, + message: string, + result: T, +} diff --git a/packages/extension-base/src/services/chain-service/utils/index.ts b/packages/extension-base/src/services/chain-service/utils/index.ts index be76faf0c8b..a0b82c063e7 100644 --- a/packages/extension-base/src/services/chain-service/utils/index.ts +++ b/packages/extension-base/src/services/chain-service/utils/index.ts @@ -7,6 +7,7 @@ import { _MANTA_ZK_CHAIN_GROUP, _ZK_ASSET_PREFIX } from '@subwallet/extension-ba import { _ChainState, _CUSTOM_PREFIX, _DataMap, _SMART_CONTRACT_STANDARDS } from '@subwallet/extension-base/services/chain-service/types'; import { IChain } from '@subwallet/extension-base/services/storage-service/databases'; import { AccountChainType } from '@subwallet/extension-base/types'; +import { BitcoinMainnetKeypairTypes, BitcoinTestnetKeypairTypes, CardanoKeypairTypes, EthereumKeypairTypes, KeypairType, SubstrateKeypairTypes, TonKeypairTypes } from '@subwallet/keyring/types'; import { isEthereumAddress } from '@polkadot/util-crypto'; @@ -62,19 +63,23 @@ export function _isEqualSmartContractAsset (asset1: _ChainAsset, asset2: _ChainA } export function _isPureEvmChain (chainInfo: _ChainInfo) { - return (!!chainInfo.evmInfo && !chainInfo.substrateInfo && !chainInfo.tonInfo && !chainInfo.cardanoInfo); + return (!!chainInfo.evmInfo && !chainInfo.substrateInfo && !chainInfo.tonInfo && !chainInfo.cardanoInfo && !chainInfo.bitcoinInfo); } export function _isPureSubstrateChain (chainInfo: _ChainInfo) { - return (!chainInfo.evmInfo && !!chainInfo.substrateInfo && !chainInfo.tonInfo && !chainInfo.cardanoInfo); + return (!chainInfo.evmInfo && !!chainInfo.substrateInfo && !chainInfo.tonInfo && !chainInfo.cardanoInfo && !chainInfo.bitcoinInfo); } export function _isPureTonChain (chainInfo: _ChainInfo) { - return (!chainInfo.evmInfo && !chainInfo.substrateInfo && !!chainInfo.tonInfo && !chainInfo.cardanoInfo); + return (!chainInfo.evmInfo && !chainInfo.substrateInfo && !!chainInfo.tonInfo && !chainInfo.cardanoInfo && !chainInfo.bitcoinInfo); } export function _isPureCardanoChain (chainInfo: _ChainInfo) { - return (!chainInfo.evmInfo && !chainInfo.substrateInfo && !chainInfo.tonInfo && !!chainInfo.cardanoInfo); + return (!chainInfo.evmInfo && !chainInfo.substrateInfo && !chainInfo.tonInfo && !!chainInfo.cardanoInfo && !chainInfo.bitcoinInfo); +} + +export function _isPureBitcoinChain (chainInfo: _ChainInfo) { + return (!chainInfo.evmInfo && !chainInfo.substrateInfo && !chainInfo.tonInfo && !chainInfo.cardanoInfo && !!chainInfo.bitcoinInfo); } export function _getOriginChainOfAsset (assetSlug: string) { @@ -138,6 +143,11 @@ export function _isTokenTransferredByCardano (tokenInfo: _ChainAsset) { return _isCIP26Token(tokenInfo) || _isNativeToken(tokenInfo); } +// TODO [Review]: Currently supports transferring only the native token, Bitcoin. +export function _isTokenTransferredByBitcoin (tokenInfo: _ChainAsset) { + return _isNativeToken(tokenInfo); +} + // Utils for balance functions export function _getTokenOnChainAssetId (tokenInfo: _ChainAsset): string { return tokenInfo.metadata?.assetId as string || '-1'; @@ -335,6 +345,11 @@ export function _getChainNativeTokenBasicInfo (chainInfo: _ChainInfo): BasicToke symbol: chainInfo.cardanoInfo.symbol, decimals: chainInfo.cardanoInfo.decimals }; + } else if (chainInfo.bitcoinInfo) { + return { + symbol: chainInfo.bitcoinInfo.symbol, + decimals: chainInfo.bitcoinInfo.decimals + }; } return defaultTokenInfo; @@ -473,6 +488,8 @@ export function _getBlockExplorerFromChain (chainInfo: _ChainInfo): string | und blockExplorer = chainInfo?.evmInfo?.blockExplorer; } else if (_isPureCardanoChain(chainInfo)) { blockExplorer = chainInfo?.cardanoInfo?.blockExplorer; + } else if (_isPureBitcoinChain(chainInfo)) { + blockExplorer = chainInfo?.bitcoinInfo?.blockExplorer; } else { blockExplorer = chainInfo?.substrateInfo?.blockExplorer; } @@ -682,6 +699,44 @@ export const _chainInfoToChainType = (chainInfo: _ChainInfo): AccountChainType = return AccountChainType.SUBSTRATE; }; +export const _isChainInfoCompatibleWithAccountInfo = (chainInfo: _ChainInfo, accountChainType: AccountChainType, accountType: KeypairType): boolean => { + if (accountChainType === AccountChainType.SUBSTRATE) { + return _isPureSubstrateChain(chainInfo) && SubstrateKeypairTypes.includes(accountType); + } + + if (accountChainType === AccountChainType.ETHEREUM) { + return _isChainEvmCompatible(chainInfo) && EthereumKeypairTypes.includes(accountType); + } + + if (accountChainType === AccountChainType.TON) { + return _isChainTonCompatible(chainInfo) && TonKeypairTypes.includes(accountType); + } + + if (accountChainType === AccountChainType.CARDANO) { + return _isChainCardanoCompatible(chainInfo) && CardanoKeypairTypes.includes(accountType); + } + + if (accountChainType === AccountChainType.BITCOIN) { + if (!_isChainBitcoinCompatible(chainInfo) || ![...BitcoinMainnetKeypairTypes, ...BitcoinTestnetKeypairTypes].includes(accountType)) { + return false; + } + + const network = chainInfo.bitcoinInfo?.bitcoinNetwork; + + if (BitcoinMainnetKeypairTypes.includes(accountType)) { + return network === 'mainnet'; + } + + if (BitcoinTestnetKeypairTypes.includes(accountType)) { + return network === 'testnet'; + } + + return false; + } + + return false; +}; + export const _getAssetNetuid = (assetInfo: _ChainAsset): number => { // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unsafe-return diff --git a/packages/extension-base/src/services/event-service/index.ts b/packages/extension-base/src/services/event-service/index.ts index 5a5a65f6017..b6d32c611fb 100644 --- a/packages/extension-base/src/services/event-service/index.ts +++ b/packages/extension-base/src/services/event-service/index.ts @@ -21,6 +21,10 @@ export class EventService extends EventEmitter { public readonly waitCryptoReady: Promise; public readonly waitDatabaseReady: Promise; + public readonly waitAppInitialized: Promise; + public readonly waitAppStart: Promise; + public readonly waitAppStartFull: Promise; + public readonly waitKeyringReady: Promise; public readonly waitAccountReady: Promise; public readonly waitInjectReady: Promise; @@ -43,6 +47,10 @@ export class EventService extends EventEmitter { this.waitDatabaseReady = this.generateWaitPromise('database.ready'); this.waitKeyringReady = this.generateWaitPromise('keyring.ready'); this.waitAccountReady = this.generateWaitPromise('account.ready'); + this.waitAppInitialized = this.generateWaitPromise('general.init'); + this.waitAppStart = this.generateWaitPromise('general.start'); + this.waitAppStartFull = this.generateWaitPromise('general.start_full'); + // TODO: Need to merge logic on web-runner file this.waitInjectReady = TARGET_ENV === 'webapp' ? this.generateWaitPromise('inject.ready') : Promise.resolve(true); diff --git a/packages/extension-base/src/services/event-service/types.ts b/packages/extension-base/src/services/event-service/types.ts index 0f3f9c1751a..c5e94b75d2c 100644 --- a/packages/extension-base/src/services/event-service/types.ts +++ b/packages/extension-base/src/services/event-service/types.ts @@ -5,6 +5,9 @@ import { SWTransactionBase } from '@subwallet/extension-base/services/transactio import { CurrentAccountInfo } from '@subwallet/extension-base/types'; export interface EventRegistry { + 'general.init': [boolean]; + 'general.start': [boolean]; + 'general.start_full': [boolean]; 'general.sleep': [boolean]; 'general.wakeup': [boolean]; 'crypto.ready': [boolean]; diff --git a/packages/extension-base/src/services/fee-service/service.ts b/packages/extension-base/src/services/fee-service/service.ts index f2336f12665..6ac6609ce85 100644 --- a/packages/extension-base/src/services/fee-service/service.ts +++ b/packages/extension-base/src/services/fee-service/service.ts @@ -17,7 +17,8 @@ export default class FeeService { evm: {}, substrate: {}, ton: {}, - cardano: {} + cardano: {}, + bitcoin: {} }; constructor (state: KoniState) { @@ -132,10 +133,10 @@ export default class FeeService { if (cancel) { clearInterval(interval); } else { - const api = this.state.getEvmApi(chain); - // TODO: Handle case type === evm and not have api if (type === 'evm') { + const api = this.state.getEvmApi(chain); + if (api) { calculateGasFeeParams(api, chain) .then((info) => { @@ -160,6 +161,14 @@ export default class FeeService { options: undefined } as EvmFeeInfo); } + } else if (type === 'bitcoin') { + const api = this.state.getBitcoinApi(chain); + + api.api.getRecommendedFeeRate() + .then((info) => { + observer.next(info); + }) + .catch(console.error); } else { observer.next({ type, diff --git a/packages/extension-base/src/services/hiro-service/index.ts b/packages/extension-base/src/services/hiro-service/index.ts new file mode 100644 index 00000000000..f125245066d --- /dev/null +++ b/packages/extension-base/src/services/hiro-service/index.ts @@ -0,0 +1,115 @@ +// Copyright 2019-2022 @subwallet/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { SWError } from '@subwallet/extension-base/background/errors/SWError'; +import { _BTC_SERVICE_TOKEN } from '@subwallet/extension-base/services/chain-service/constants'; +import { Brc20BalanceFetchedData, Brc20MetadataFetchedData, InscriptionFetchedData } from '@subwallet/extension-base/services/chain-service/handler/bitcoin/strategy/types'; +import { OBResponse } from '@subwallet/extension-base/services/chain-service/types'; +import { BaseApiRequestStrategy } from '@subwallet/extension-base/strategy/api-request-strategy'; +import { BaseApiRequestContext } from '@subwallet/extension-base/strategy/api-request-strategy/context/base'; +import { getRequest } from '@subwallet/extension-base/strategy/api-request-strategy/utils'; + +const BITCOIN_API_URL = 'https://btc-api.koni.studio'; +const BITCOIN_API_URL_TEST = 'https://api-testnet.openbit.app'; + +export class HiroService extends BaseApiRequestStrategy { + baseUrl: string; + + private constructor (url: string) { + const context = new BaseApiRequestContext(); + + super(context); + + this.baseUrl = url; + } + + private headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${_BTC_SERVICE_TOKEN}` + }; + + isRateLimited (): boolean { + return false; + } + + getUrl (path: string): string { + return `${this.baseUrl}/${path}`; + } + + getBRC20Metadata (ticker: string): Promise { + return this.addRequest(async () => { + const _rs = await getRequest(this.getUrl(`brc-20/tokens/${ticker}`), undefined, this.headers); + const rs = await _rs.json() as OBResponse; + + if (rs.status_code !== 200) { + throw new SWError('HiroService.getBRC20Metadata', rs.message); + } + + return rs.result; + }, 3); + } + + getAddressBRC20BalanceInfo (address: string, params: Record): Promise { + return this.addRequest(async () => { + const _rs = await getRequest(this.getUrl(`brc-20/balances/${address}`), params, this.headers); + const rs = await _rs.json() as OBResponse; + + if (rs.status_code !== 200) { + throw new SWError('HiroService.getAddressBRC20BalanceInfo', rs.message); + } + + return rs.result; + }, 3); + } + + getAddressInscriptionsInfo (params: Record): Promise { + return this.addRequest(async () => { + const _rs = await getRequest(this.getUrl('inscriptions'), params, this.headers); + const rs = await _rs.json() as OBResponse; + + if (rs.status_code !== 200) { + throw new SWError('HiroService.getAddressInscriptionsInfo', rs.message); + } + + return rs.result; + }, 0); + } + + getInscriptionContent (inscriptionId: string): Promise> { + return this.addRequest(async () => { + const _rs = await getRequest(this.getUrl(`inscriptions/${inscriptionId}/content`), undefined, this.headers); + const rs = await _rs.json() as OBResponse>; + + if (rs.status_code !== 200) { + throw new SWError('HiroService.getInscriptionContent', rs.message); + } + + return rs.result; + }, 0); + } + + // todo: handle token authen for url preview + getPreviewUrl (inscriptionId: string) { + return `${BITCOIN_API_URL}/inscriptions/${inscriptionId}/content`; + } + + // Singleton + private static mainnet: HiroService; + private static testnet: HiroService; + + public static getInstance (isTestnet = false) { + if (isTestnet) { + if (!HiroService.testnet) { + HiroService.testnet = new HiroService(BITCOIN_API_URL_TEST); + } + + return HiroService.testnet; + } else { + if (!HiroService.mainnet) { + HiroService.mainnet = new HiroService(BITCOIN_API_URL); + } + + return HiroService.mainnet; + } + } +} diff --git a/packages/extension-base/src/services/hiro-service/utils/index.ts b/packages/extension-base/src/services/hiro-service/utils/index.ts new file mode 100644 index 00000000000..082218c411a --- /dev/null +++ b/packages/extension-base/src/services/hiro-service/utils/index.ts @@ -0,0 +1,87 @@ +// Copyright 2019-2022 @subwallet/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { Brc20Metadata, InscriptionFetchedData } from '@subwallet/extension-base/services/chain-service/handler/bitcoin/strategy/types'; +import { HiroService } from '@subwallet/extension-base/services/hiro-service'; + +// todo: handle inscription testnet +export async function getBrc20Metadata (isTestnet = false, ticker: string) { + const hiroService = HiroService.getInstance(isTestnet); + const defaultMetadata = { + ticker: '', + decimals: 0 + } as Brc20Metadata; + + try { + const response = await hiroService.getBRC20Metadata(ticker); + const rs = response?.token; + + if (rs) { + return { + ticker: rs.ticker, + decimals: rs.decimals + } as Brc20Metadata; + } + + return defaultMetadata; + } catch (error) { + console.log(`Error on request brc20 metadata with ticker ${ticker}`); + + return defaultMetadata; + } +} + +export async function getInscriptionContent (isTestnet: boolean, inscriptionId: string) { + const hiroService = HiroService.getInstance(isTestnet); + + try { + return await hiroService.getInscriptionContent(inscriptionId); + } catch (error) { + console.log(`Error on request inscription ${inscriptionId} content`); + + return {}; + } +} + +// todo: handle large inscriptions +export async function getAddressInscriptions (address: string, isTestnet: boolean, offset = 0, limit = 25) { + const hiroService = HiroService.getInstance(isTestnet); + + try { + const response = await hiroService.getAddressInscriptionsInfo({ + limit: String(limit), + offset: String(offset), + address: String(address) + }) as unknown as InscriptionFetchedData; + + return response.results; + } catch (error) { + console.error(`Failed to get ${address} inscriptions with offset ${offset} and limit ${limit}`, error); + throw error; + } +} + +export function getPreviewUrl (inscriptionId: string) { + const hiroService = HiroService.getInstance(); + + try { + return hiroService.getPreviewUrl(inscriptionId); + } catch (error) { + console.error(`Failed to get inscription ${inscriptionId} preview url`, error); + throw error; + } +} + +export function isValidBrc20Ticker (ticker: string) { + const bytesLength = getByteLength(ticker); + + return bytesLength === 4 || bytesLength === 5; +} + +function getByteLength (str: string): number { + const encoder = new TextEncoder(); + const encodedStr = encoder.encode(str); + + // Return the length of the encoded array, which represents the number of bytes + return encodedStr.length; +} diff --git a/packages/extension-base/src/services/history-service/bitcoin-history.ts b/packages/extension-base/src/services/history-service/bitcoin-history.ts new file mode 100644 index 00000000000..26de897a954 --- /dev/null +++ b/packages/extension-base/src/services/history-service/bitcoin-history.ts @@ -0,0 +1,59 @@ +// Copyright 2019-2022 @subwallet/extension-koni authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { _ChainInfo } from '@subwallet/chain-list/types'; +import { ChainType, ExtrinsicStatus, ExtrinsicType, TransactionDirection, TransactionHistoryItem } from '@subwallet/extension-base/background/KoniTypes'; +import { BitcoinTx } from '@subwallet/extension-base/types'; + +function isSender (address: string, transferItem: BitcoinTx) { + return transferItem.vin.some((i) => i.prevout.scriptpubkey_address === address); +} + +export function parseBitcoinTransferData (address: string, transferItem: BitcoinTx, chainInfo: _ChainInfo): TransactionHistoryItem { + const chainType = ChainType.BITCOIN; + const nativeDecimals = chainInfo.bitcoinInfo?.decimals || 8; + const nativeSymbol = chainInfo.bitcoinInfo?.symbol || ''; + + const isCurrentAddressSender = isSender(address, transferItem); + + const sender = isCurrentAddressSender ? address : transferItem.vin[0]?.prevout?.scriptpubkey_address || ''; + const receiver = isCurrentAddressSender ? transferItem.vout[0]?.scriptpubkey_address || '' : address; + + const amountValue = (() => { + const targetAddress = isCurrentAddressSender ? receiver : address; + const vouts = transferItem.vout.filter((i) => i.scriptpubkey_address === targetAddress); + + if (vouts.length) { + return vouts.reduce((total, item) => total + item.value, 0).toString(); + } else { + return '0'; + } + })(); + + return { + address, + origin: 'blockstream', + time: 0, // From api, cannot get time submit transaction + blockTime: transferItem.status.block_time ? transferItem.status.block_time * 1000 : undefined, + chainType, + type: ExtrinsicType.TRANSFER_BALANCE, + extrinsicHash: transferItem.txid, + chain: chainInfo.slug, + direction: address === sender ? TransactionDirection.SEND : TransactionDirection.RECEIVED, + fee: { + value: `${transferItem.fee}`, + decimals: nativeDecimals, + symbol: nativeSymbol + }, + from: sender, + to: receiver, + blockNumber: transferItem.status.block_height || 0, + blockHash: transferItem.status.block_hash || '', + amount: { + value: amountValue, + decimals: nativeDecimals, + symbol: nativeSymbol + }, + status: transferItem.status.confirmed ? ExtrinsicStatus.SUCCESS : ExtrinsicStatus.PROCESSING + }; +} diff --git a/packages/extension-base/src/services/history-service/helpers/recoverHistoryStatus.ts b/packages/extension-base/src/services/history-service/helpers/recoverHistoryStatus.ts index f255fd41e31..d94900db829 100644 --- a/packages/extension-base/src/services/history-service/helpers/recoverHistoryStatus.ts +++ b/packages/extension-base/src/services/history-service/helpers/recoverHistoryStatus.ts @@ -16,7 +16,8 @@ export enum HistoryRecoverStatus { API_INACTIVE = 'API_INACTIVE', LACK_INFO = 'LACK_INFO', FAIL_DETECT = 'FAIL_DETECT', - UNKNOWN = 'UNKNOWN' + UNKNOWN = 'UNKNOWN', + TX_PENDING = 'TX_PENDING', } export interface TransactionRecoverResult { @@ -24,6 +25,7 @@ export interface TransactionRecoverResult { extrinsicHash?: string; blockHash?: string; blockNumber?: number; + blockTime?: number; } const BLOCK_LIMIT = 6; @@ -213,17 +215,96 @@ const evmRecover = async (history: TransactionHistoryItem, chainService: ChainSe } }; +const bitcoinRecover = async (history: TransactionHistoryItem, chainService: ChainService): Promise => { + const { chain, extrinsicHash } = history; + const result: TransactionRecoverResult = { + status: HistoryRecoverStatus.UNKNOWN + }; + + // TODO: 1. Consider rebroadcasting transaction if stuck in mempool + + try { + const bitcoinApi = chainService.getBitcoinApi(chain); + + if (bitcoinApi) { + const api = bitcoinApi.api; + + if (extrinsicHash) { + try { + const timeout = new Promise((resolve) => { + setTimeout(() => { + resolve(undefined); + }, 60000); + }); + const txStatus = await Promise.race([api.getTransactionStatus(extrinsicHash), timeout]); + + if (!txStatus) { + return { ...result, status: HistoryRecoverStatus.API_INACTIVE }; + } + + if (txStatus.confirmed) { + const transactionDetail = await Promise.race([api.getTransactionDetail(extrinsicHash), timeout]); + + if (transactionDetail) { + result.blockHash = transactionDetail.status.block_hash || undefined; + result.blockNumber = transactionDetail.status.block_height || undefined; + result.blockTime = transactionDetail.status.block_time ? (transactionDetail.status.block_time * 1000) : undefined; + + return { ...result, status: HistoryRecoverStatus.SUCCESS }; + } + + return { ...result, status: HistoryRecoverStatus.API_INACTIVE }; + } else { + return { ...result, status: HistoryRecoverStatus.TX_PENDING }; + } + } catch (e) { + // Fail when cannot find transaction + return { ...result, status: HistoryRecoverStatus.FAILED }; + } + } + + return { status: HistoryRecoverStatus.FAIL_DETECT }; + } else { + console.error(`Fail to update history ${chain}-${extrinsicHash}: Api not active`); + + return { status: HistoryRecoverStatus.API_INACTIVE }; + } + } catch (e) { + console.error(`Fail to update history ${chain}-${extrinsicHash}:`, (e as Error).message); + + return { status: HistoryRecoverStatus.UNKNOWN }; + } +}; + // undefined: Cannot check status // true: Transaction success // false: Transaction failed export const historyRecover = async (history: TransactionHistoryItem, chainService: ChainService): Promise => { const { chainType } = history; - if (chainType) { - const checkFunction = chainType === 'substrate' ? substrateRecover : evmRecover; + if (!chainType) { + return { status: HistoryRecoverStatus.LACK_INFO }; + } + + const recoverFunctions: Record Promise> = { + substrate: substrateRecover, + evm: evmRecover, + bitcoin: bitcoinRecover + }; + + const checkFunction = recoverFunctions[chainType]; + + if (!checkFunction) { + console.warn(`Chain type ${chainType} is not supported for recoverHistory`); + return { status: HistoryRecoverStatus.UNKNOWN }; + } + + try { return await checkFunction(history, chainService); - } else { - return { status: HistoryRecoverStatus.LACK_INFO }; + } catch (error) { + console.error(`Failed to recover history for chain type ${chainType}:`, error); + + return { status: HistoryRecoverStatus.FAILED }; } }; diff --git a/packages/extension-base/src/services/history-service/index.ts b/packages/extension-base/src/services/history-service/index.ts index 77732063176..eb9d9bef883 100644 --- a/packages/extension-base/src/services/history-service/index.ts +++ b/packages/extension-base/src/services/history-service/index.ts @@ -5,8 +5,9 @@ import { ChainType, ExtrinsicStatus, ExtrinsicType, TransactionHistoryItem, XCMT import { CRON_RECOVER_HISTORY_INTERVAL } from '@subwallet/extension-base/constants'; import { PersistDataServiceInterface, ServiceStatus, StoppableServiceInterface } from '@subwallet/extension-base/services/base/types'; import { ChainService } from '@subwallet/extension-base/services/chain-service'; -import { _isChainEvmCompatible, _isChainSubstrateCompatible } from '@subwallet/extension-base/services/chain-service/utils'; +import { _isChainBitcoinCompatible, _isChainEvmCompatible, _isChainSubstrateCompatible } from '@subwallet/extension-base/services/chain-service/utils'; import { EventService } from '@subwallet/extension-base/services/event-service'; +import { parseBitcoinTransferData } from '@subwallet/extension-base/services/history-service/bitcoin-history'; import { historyRecover, HistoryRecoverStatus } from '@subwallet/extension-base/services/history-service/helpers/recoverHistoryStatus'; import { getExtrinsicParserKey } from '@subwallet/extension-base/services/history-service/helpers/subscan-extrinsic-parser-helper'; import { parseSubscanExtrinsicData, parseSubscanTransferData } from '@subwallet/extension-base/services/history-service/subscan-history'; @@ -179,23 +180,54 @@ export class HistoryService implements StoppableServiceInterface, PersistDataSer }); } + // Only 1 address is passed in + private async fetchBitcoinTransactionHistory (chain: string, addresses: string[]) { + const chainInfo = this.chainService.getChainInfoByKey(chain); + const chainState = this.chainService.getChainStateByKey(chain); + + if (!chainState.active) { + return; + } + + const bitcoinApi = this.chainService.getBitcoinApi(chain); + const allParsedItems: TransactionHistoryItem[] = []; + + for (const address of addresses) { + const transferItems = await bitcoinApi.api.getAddressTransaction(address); + + const parsedItems = transferItems.map((item, index) => { + const parsedItem = parseBitcoinTransferData(address, item, chainInfo); + + return { ...parsedItem, apiTxIndex: index }; + }); + + allParsedItems.push(...parsedItems); + } + + await this.addHistoryItems(allParsedItems); + } + subscribeHistories (chain: string, proxyId: string, cb: (items: TransactionHistoryItem[]) => void) { const addresses = this.keyringService.context.getDecodedAddresses(proxyId, false); + const chainInfo = this.chainService.getChainInfoByKey(chain); const evmAddresses = getAddressesByChainType(addresses, [ChainType.EVM]); const substrateAddresses = getAddressesByChainType(addresses, [ChainType.SUBSTRATE]); + const bitcoinAddresses = getAddressesByChainType(addresses, [ChainType.BITCOIN], chainInfo); const subscription = this.historySubject.subscribe((items) => { cb(items.filter(filterHistoryItemByAddressAndChain(chain, addresses))); }); - const chainInfo = this.chainService.getChainInfoByKey(chain); - if (_isChainSubstrateCompatible(chainInfo)) { if (_isChainEvmCompatible(chainInfo)) { this.fetchSubscanTransactionHistory(chain, evmAddresses); } else { this.fetchSubscanTransactionHistory(chain, substrateAddresses); } + } else if (_isChainBitcoinCompatible(chainInfo)) { + this.fetchBitcoinTransactionHistory(chain, bitcoinAddresses).catch((e) => { + console.log('fetchBitcoinTransactionHistory Error', e); + }); } return { @@ -235,7 +267,7 @@ export class HistoryService implements StoppableServiceInterface, PersistDataSer (item_) => item_.extrinsicHash === item.extrinsicHash && item.chain === item_.chain && item.address === item_.address); if (needUpdateItem) { - updateRecords.push({ ...needUpdateItem, status: item.status }); + updateRecords.push({ ...needUpdateItem, status: item.status, apiTxIndex: item.apiTxIndex }); return; } @@ -308,6 +340,9 @@ export class HistoryService implements StoppableServiceInterface, PersistDataSer switch (recoverResult.status) { case HistoryRecoverStatus.API_INACTIVE: break; + case HistoryRecoverStatus.TX_PENDING: + delete this.#needRecoveryHistories[currentExtrinsicHash]; + break; case HistoryRecoverStatus.FAILED: case HistoryRecoverStatus.SUCCESS: updateData.status = recoverResult.status === HistoryRecoverStatus.SUCCESS ? ExtrinsicStatus.SUCCESS : ExtrinsicStatus.FAIL; @@ -354,7 +389,13 @@ export class HistoryService implements StoppableServiceInterface, PersistDataSer histories .filter((history) => { - return [ExtrinsicStatus.PROCESSING, ExtrinsicStatus.SUBMITTING].includes(history.status); + if ([ExtrinsicStatus.PROCESSING, ExtrinsicStatus.SUBMITTING].includes(history.status)) { + return true; + } else if (history.status === ExtrinsicStatus.SUCCESS && history.chainType === 'bitcoin') { + return !history.blockTime; + } + + return false; }) .filter((history) => { if (history.type === ExtrinsicType.TRANSFER_XCM) { diff --git a/packages/extension-base/src/services/keyring-service/context/handlers/Derive.ts b/packages/extension-base/src/services/keyring-service/context/handlers/Derive.ts index 69fbfce229e..4537628a568 100644 --- a/packages/extension-base/src/services/keyring-service/context/handlers/Derive.ts +++ b/packages/extension-base/src/services/keyring-service/context/handlers/Derive.ts @@ -4,7 +4,7 @@ import { ALL_ACCOUNT_KEY } from '@subwallet/extension-base/constants'; import { AccountJson, AccountProxyData, CommonAccountErrorType, CreateDeriveAccountInfo, DeriveAccountInfo, DeriveErrorType, NextDerivePair, RequestDeriveCreateMultiple, RequestDeriveCreateV3, RequestDeriveValidateV2, RequestGetDeriveAccounts, RequestGetDeriveSuggestion, ResponseDeriveValidateV2, ResponseGetDeriveAccounts, ResponseGetDeriveSuggestion, SWCommonAccountError, SWDeriveError } from '@subwallet/extension-base/types'; import { createAccountProxyId, derivePair, findSoloNextDerive, findUnifiedNextDerive, getSoloDerivationInfo, parseUnifiedSuriToDerivationPath, validateDerivationPath } from '@subwallet/extension-base/utils'; -import { EthereumKeypairTypes, KeypairType, KeyringPair, KeyringPair$Meta, SubstrateKeypairTypes } from '@subwallet/keyring/types'; +import { BitcoinKeypairTypes, EthereumKeypairTypes, KeypairType, KeyringPair, KeyringPair$Meta, SubstrateKeypairTypes } from '@subwallet/keyring/types'; import { keyring } from '@subwallet/ui-keyring'; import { t } from 'i18next'; @@ -12,7 +12,7 @@ import { assert } from '@polkadot/util'; import { AccountBaseHandler } from './Base'; -const validDeriveKeypairTypes: KeypairType[] = [...SubstrateKeypairTypes, ...EthereumKeypairTypes, 'ton', 'cardano']; +const validDeriveKeypairTypes: KeypairType[] = [...SubstrateKeypairTypes, ...EthereumKeypairTypes, 'ton', 'cardano', ...BitcoinKeypairTypes]; /** * @class AccountDeriveHandler diff --git a/packages/extension-base/src/services/keyring-service/context/handlers/Migration.ts b/packages/extension-base/src/services/keyring-service/context/handlers/Migration.ts index 407d9d42d62..05795c25bc7 100644 --- a/packages/extension-base/src/services/keyring-service/context/handlers/Migration.ts +++ b/packages/extension-base/src/services/keyring-service/context/handlers/Migration.ts @@ -92,7 +92,7 @@ export class AccountMigrationHandler extends AccountBaseHandler { }).result; const newChainTypes = Object.values(AccountChainType).filter((type) => !unifiedAccount.chainTypes.includes(type) && SUPPORTED_ACCOUNT_CHAIN_TYPES.includes(type)); - const keypairTypes = newChainTypes.map((chainType) => getDefaultKeypairTypeFromAccountChainType(chainType)); + const keypairTypes = newChainTypes.flatMap((chainType) => getDefaultKeypairTypeFromAccountChainType(chainType)); keypairTypes.forEach((type) => { const suri = getSuri(mnemonic, type); @@ -208,7 +208,7 @@ export class AccountMigrationHandler extends AccountBaseHandler { try { const mnemonic = this.parentService.context.exportAccountProxyMnemonic({ password, proxyId: firstAccountOldProxyId }).result; - const keypairTypes = SUPPORTED_ACCOUNT_CHAIN_TYPES.map((chainType) => getDefaultKeypairTypeFromAccountChainType(chainType as AccountChainType)); + const keypairTypes = SUPPORTED_ACCOUNT_CHAIN_TYPES.flatMap((chainType) => getDefaultKeypairTypeFromAccountChainType(chainType)); keypairTypes.forEach((type) => { const suri = getSuri(mnemonic, type); diff --git a/packages/extension-base/src/services/keyring-service/context/handlers/Mnemonic.ts b/packages/extension-base/src/services/keyring-service/context/handlers/Mnemonic.ts index 402321ea543..68b2a47ae42 100644 --- a/packages/extension-base/src/services/keyring-service/context/handlers/Mnemonic.ts +++ b/packages/extension-base/src/services/keyring-service/context/handlers/Mnemonic.ts @@ -4,7 +4,7 @@ import { CommonAccountErrorType, MnemonicType, RequestAccountCreateSuriV2, RequestExportAccountProxyMnemonic, RequestMnemonicCreateV2, RequestMnemonicValidateV2, ResponseAccountCreateSuriV2, ResponseExportAccountProxyMnemonic, ResponseMnemonicCreateV2, ResponseMnemonicValidateV2, SWCommonAccountError } from '@subwallet/extension-base/types'; import { createAccountProxyId, getSuri } from '@subwallet/extension-base/utils'; import { tonMnemonicGenerate } from '@subwallet/keyring'; -import { KeypairType, KeyringPair } from '@subwallet/keyring/types'; +import { BitcoinKeypairTypes, CardanoKeypairTypes, EthereumKeypairTypes, KeypairType, KeyringPair } from '@subwallet/keyring/types'; import { tonMnemonicValidate } from '@subwallet/keyring/utils'; import { keyring } from '@subwallet/ui-keyring'; import { t } from 'i18next'; @@ -27,7 +27,7 @@ export class AccountMnemonicHandler extends AccountBaseHandler { /* Create seed */ public async mnemonicCreateV2 ({ length = SEED_DEFAULT_LENGTH, mnemonic: _seed, type = 'general' }: RequestMnemonicCreateV2): Promise { - const types: KeypairType[] = type === 'general' ? ['sr25519', 'ethereum', 'ton', 'cardano'] : ['ton-native']; + const types: KeypairType[] = type === 'general' ? ['sr25519', ...EthereumKeypairTypes, 'ton', ...CardanoKeypairTypes, ...BitcoinKeypairTypes] : ['ton-native']; const seed = _seed || type === 'general' ? mnemonicGenerate(length) @@ -57,7 +57,7 @@ export class AccountMnemonicHandler extends AccountBaseHandler { assert(mnemonicValidate(phrase), t('Invalid seed phrase. Please try again.')); mnemonicTypes = 'general'; - pairTypes = ['sr25519', 'ethereum', 'ton']; + pairTypes = ['sr25519', ...EthereumKeypairTypes, 'ton', ...CardanoKeypairTypes, ...BitcoinKeypairTypes]; } catch (e) { assert(tonMnemonicValidate(phrase), t('Invalid seed phrase. Please try again.')); mnemonicTypes = 'ton'; @@ -89,7 +89,7 @@ export class AccountMnemonicHandler extends AccountBaseHandler { const addressDict = {} as Record; let changedAccount = false; const hasMasterPassword = keyring.keyring.hasMasterPassword; - const types: KeypairType[] = type ? [type] : ['sr25519', 'ethereum', 'ton', 'cardano']; + const types: KeypairType[] = type ? [type] : ['sr25519', ...EthereumKeypairTypes, 'ton', ...CardanoKeypairTypes, ...BitcoinKeypairTypes]; if (!hasMasterPassword) { if (!password) { diff --git a/packages/extension-base/src/services/migration-service/scripts/MigrateNewUnifiedAccount.ts b/packages/extension-base/src/services/migration-service/scripts/MigrateNewUnifiedAccount.ts new file mode 100644 index 00000000000..5e7a1777428 --- /dev/null +++ b/packages/extension-base/src/services/migration-service/scripts/MigrateNewUnifiedAccount.ts @@ -0,0 +1,23 @@ +// Copyright 2019-2022 @subwallet/extension-koni authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import BaseMigrationJob from '@subwallet/extension-base/services/migration-service/Base'; + +export default class MigrateNewUnifiedAccount extends BaseMigrationJob { + public override async run (): Promise { + try { + return new Promise((resolve) => { + this.state.settingService.getSettings((currentSettings) => { + this.state.settingService.setSettings({ + ...currentSettings, + isAcknowledgedUnifiedAccountMigration: false + }); + + resolve(); + }); + }); + } catch (e) { + console.error(e); + } + } +} diff --git a/packages/extension-base/src/services/migration-service/scripts/index.ts b/packages/extension-base/src/services/migration-service/scripts/index.ts index a2c217ebc86..c8d052b92fc 100644 --- a/packages/extension-base/src/services/migration-service/scripts/index.ts +++ b/packages/extension-base/src/services/migration-service/scripts/index.ts @@ -23,6 +23,7 @@ import EnableVaraChain from './EnableVaraChain'; import MigrateAuthUrls from './MigrateAuthUrls'; import MigrateImportedToken from './MigrateImportedToken'; import MigrateNetworkSettings from './MigrateNetworkSettings'; +import MigrateNewUnifiedAccount from './MigrateNewUnifiedAccount'; import MigrateTokenDecimals from './MigrateTokenDecimals'; import MigrateTransactionHistory from './MigrateTransactionHistory'; import MigrateTransactionHistoryBridge from './MigrateTransactionHistoryBridge'; @@ -65,7 +66,8 @@ export default >{ '1.3.6-01': MigrateTransactionHistoryBridge, '1.3.10-01': ClearMetadataDatabase, '1.3.26-01': DisableZeroBalanceTokens, - [MYTHOS_MIGRATION_KEY]: ClearMetadataForMythos + [MYTHOS_MIGRATION_KEY]: ClearMetadataForMythos, // [`${EVERYTIME}-1.1.42-02`]: MigrateTransactionHistoryBySymbol // [`${EVERYTIME}-1`]: AutoEnableChainsTokens + '1.3.42-01': MigrateNewUnifiedAccount }; diff --git a/packages/extension-base/src/services/request-service/handler/BitcoinRequestHandler.ts b/packages/extension-base/src/services/request-service/handler/BitcoinRequestHandler.ts new file mode 100644 index 00000000000..b2caba24964 --- /dev/null +++ b/packages/extension-base/src/services/request-service/handler/BitcoinRequestHandler.ts @@ -0,0 +1,465 @@ +// Copyright 2019-2022 @subwallet/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { BitcoinProviderError } from '@subwallet/extension-base/background/errors/BitcoinProviderError'; +import { TransactionError } from '@subwallet/extension-base/background/errors/TransactionError'; +import { BitcoinProviderErrorType, ConfirmationDefinitionsBitcoin, ConfirmationsQueueBitcoin, ConfirmationsQueueItemOptions, ConfirmationTypeBitcoin, ExtrinsicDataTypeMap, RequestConfirmationCompleteBitcoin, SignMessageBitcoinResult, SignPsbtBitcoinResult } from '@subwallet/extension-base/background/KoniTypes'; +import { ConfirmationRequestBase, Resolver } from '@subwallet/extension-base/background/types'; +import { createBitcoinTransaction } from '@subwallet/extension-base/services/balance-service/transfer/bitcoin-transfer'; +import { ChainService } from '@subwallet/extension-base/services/chain-service'; +import FeeService from '@subwallet/extension-base/services/fee-service/service'; +import RequestService from '@subwallet/extension-base/services/request-service'; +import TransactionService from '@subwallet/extension-base/services/transaction-service'; +import { TransactionEventResponse } from '@subwallet/extension-base/services/transaction-service/types'; +import { BasicTxErrorType } from '@subwallet/extension-base/types'; +import { createPromiseHandler } from '@subwallet/extension-base/utils'; +import { getId } from '@subwallet/extension-base/utils/getId'; +import { isInternalRequest } from '@subwallet/extension-base/utils/request'; +import keyring from '@subwallet/ui-keyring'; +import { Psbt } from 'bitcoinjs-lib'; +import * as bitcoin from 'bitcoinjs-lib'; +import { t } from 'i18next'; +import { BehaviorSubject } from 'rxjs'; + +import { isArray, logger as createLogger } from '@polkadot/util'; +import { Logger } from '@polkadot/util/types'; + +export default class BitcoinRequestHandler { + readonly #requestService: RequestService; + readonly #chainService: ChainService; + readonly #transactionService: TransactionService; + readonly #feeService: FeeService; + readonly #logger: Logger; + private readonly confirmationsQueueSubjectBitcoin = new BehaviorSubject({ + bitcoinSignatureRequest: {}, + bitcoinSendTransactionRequest: {}, + bitcoinWatchTransactionRequest: {}, + bitcoinSendTransactionRequestAfterConfirmation: {}, + bitcoinSignPsbtRequest: {} + }); + + private readonly confirmationsPromiseMap: Record, validator?: (rs: any) => Error | undefined }> = {}; + + constructor (requestService: RequestService, chainService: ChainService, feeService: FeeService, transactionService: TransactionService) { + this.#requestService = requestService; + this.#chainService = chainService; + this.#feeService = feeService; + this.#transactionService = transactionService; + this.#logger = createLogger('BitcoinRequestHandler'); + } + + public get numBitcoinRequests (): number { + let count = 0; + + Object.values(this.confirmationsQueueSubjectBitcoin.getValue()).forEach((x) => { + count += Object.keys(x).length; + }); + + return count; + } + + public getConfirmationsQueueSubjectBitcoin (): BehaviorSubject { + return this.confirmationsQueueSubjectBitcoin; + } + + public async addConfirmationBitcoin ( + id: string, + url: string, + type: CT, + payload: ConfirmationDefinitionsBitcoin[CT][0]['payload'], + options: ConfirmationsQueueItemOptions = {}, + validator?: (input: ConfirmationDefinitionsBitcoin[CT][1]) => Error | undefined + ): Promise { + const confirmations = this.confirmationsQueueSubjectBitcoin.getValue(); + const confirmationType = confirmations[type] as Record; + const payloadJson = JSON.stringify(payload); + const isInternal = isInternalRequest(url); + + if (['bitcoinSignatureRequest', 'bitcoinSendTransactionRequest', 'bitcoinSendTransactionRequestAfterConfirmation'].includes(type)) { + const isAlwaysRequired = await this.#requestService.settingService.isAlwaysRequired; + + if (isAlwaysRequired) { + this.#requestService.keyringService.lock(); + } + } + + // Check duplicate request + const duplicated = Object.values(confirmationType).find((c) => (c.url === url) && (c.payloadJson === payloadJson)); + + if (duplicated) { + throw new Error('Duplicate request'); + } + + confirmationType[id] = { + id, + url, + isInternal, + payload, + payloadJson, + ...options + } as ConfirmationDefinitionsBitcoin[CT][0]; + + const promise = new Promise((resolve, reject) => { + this.confirmationsPromiseMap[id] = { + validator: validator, + resolver: { + resolve: resolve, + reject: reject + } + }; + }); + + this.confirmationsQueueSubjectBitcoin.next(confirmations); + + if (!isInternal) { + this.#requestService.popupOpen(); + } + + this.#requestService.updateIconV2(); + + return promise; + } + + public updateConfirmationBitcoin ( + id: string, + type: CT, + payload: ConfirmationDefinitionsBitcoin[CT][0]['payload'], + options: ConfirmationsQueueItemOptions = {}, + validator?: (input: ConfirmationDefinitionsBitcoin[CT][1]) => Error | undefined + ) { + const confirmations = this.confirmationsQueueSubjectBitcoin.getValue(); + const confirmationType = confirmations[type] as Record; + + // Check duplicate request + const exists = confirmationType[id]; + + if (!exists) { + throw new Error('Request does not exist'); + } + + const payloadJson = JSON.stringify(payload); + + confirmationType[id] = { + ...exists, + payload, + payloadJson, + ...options + } as ConfirmationDefinitionsBitcoin[CT][0]; + + if (validator) { + this.confirmationsPromiseMap[id].validator = validator; + } + + this.confirmationsQueueSubjectBitcoin.next(confirmations); + } + + signMessageBitcoin (confirmation: ConfirmationDefinitionsBitcoin['bitcoinSignatureRequest'][0]): SignMessageBitcoinResult { + const { account, payload } = confirmation.payload; + const address = account.address; + const pair = keyring.getPair(address); + + if (pair.isLocked) { + keyring.unlockPair(pair.address); + } + + // Check if payload is a string + if (typeof payload === 'string') { + // Assume BitcoinSigner is an instance that implements the BitcoinSigner interface + return { + signature: pair.bitcoin.signMessage(payload), + message: payload, + address + }; // Assuming compressed = false + } else if (payload instanceof Uint8Array) { // Check if payload is a byte array (Uint8Array) + // Convert Uint8Array to string + const payloadString = Buffer.from(payload).toString('hex'); + + // Assume BitcoinSigner is an instance that implements the BitcoinSigner interface + return { + signature: pair.bitcoin.signMessage(payloadString), + message: payload.toString(), + address + }; // Assuming compressed = false + } else { + // Handle the case where payload is invalid + throw new Error('Invalid payload type'); + } + } + + private signTransactionBitcoin (request: ConfirmationDefinitionsBitcoin['bitcoinSendTransactionRequest'][0]): string { + // Extract necessary information from the BitcoinSendTransactionRequest + const { account, hashPayload } = request.payload; + const address = account.address; + const pair = keyring.getPair(address); + + // Unlock the pair if it is locked + if (pair.isLocked) { + keyring.unlockPair(pair.address); + } + + const psbt = Psbt.fromHex(hashPayload); + + // Finalize all inputs in the Psbt + // Sign the Psbt using the pair's bitcoin object + const signedTransaction = pair.bitcoin.signTransaction(psbt, psbt.txInputs.map((v, i) => i)); + + signedTransaction.finalizeAllInputs(); + + return signedTransaction.extractTransaction().toHex(); + } + + private async signTransactionBitcoinWithPayload (request: ConfirmationDefinitionsBitcoin['bitcoinSendTransactionRequestAfterConfirmation'][0]): Promise { + const transaction = this.#transactionService.getTransaction(request.id); + const { chain, emitterTransaction, feeCustom, feeOption, id } = transaction; + const { from, to, value } = transaction.data as ExtrinsicDataTypeMap['transfer.balance']; + + if (!emitterTransaction) { + throw new BitcoinProviderError(BitcoinProviderErrorType.INTERNAL_ERROR); + } + + const chainInfo = this.#chainService.getChainInfoByKey(chain); + const bitcoinApi = this.#chainService.getBitcoinApi(chain); + const eventData: TransactionEventResponse = { + id, + errors: [], + warnings: [], + extrinsicHash: id + }; + + const network = chainInfo.isTestnet ? bitcoin.networks.testnet : bitcoin.networks.bitcoin; + const feeInfo = await this.#feeService.subscribeChainFee(getId(), chain, 'bitcoin'); + const [psbt] = await createBitcoinTransaction({ + bitcoinApi, + chain, + from, + feeCustom, + feeOption, + feeInfo, + to, + transferAll: false, + value: value || '0', + network + }); + + const pair = keyring.getPair(from); + + // Unlock the pair if it is locked + if (pair.isLocked) { + keyring.unlockPair(pair.address); + } + + // Finalize all inputs in the Psbt + + // Sign the Psbt using the pair's bitcoin object + const signedTransaction = pair.bitcoin.signTransaction(psbt, psbt.txInputs.map((v, i) => i)); + + signedTransaction.finalizeAllInputs(); + + const signature = signedTransaction.extractTransaction().toHex(); + + this.#transactionService.emitterEventTransaction(emitterTransaction, eventData, chainInfo.slug, signature); + + const { promise, reject, resolve } = createPromiseHandler(); + + emitterTransaction.on('extrinsicHash', (data) => { + if (!data.extrinsicHash) { + reject(BitcoinProviderErrorType.INTERNAL_ERROR); + } else { + resolve(data.extrinsicHash); + } + }); + + emitterTransaction.on('error', (error) => { + reject(error); + }); + + return promise; + } + + private async signPsbt (request: ConfirmationDefinitionsBitcoin['bitcoinSignPsbtRequest'][0]): Promise { + // Extract necessary information from the BitcoinSendTransactionRequest + const { account, payload } = request.payload; + const { allowedSighash, broadcast, psbt, signAtIndex } = payload; + const transaction = this.#transactionService.getTransaction(request.id); + let eventData: TransactionEventResponse = { + id: request.id, + errors: [], + warnings: [], + extrinsicHash: request.id + }; + + // todo: validate type of the account + + if (Object.keys(account).length === 0) { + throw new BitcoinProviderError(BitcoinProviderErrorType.INVALID_PARAMS, 'Please connect to Wallet to try this request'); + } + + const pair = keyring.getPair(account.address); + + // Unlock the pair if it is locked + if (pair.isLocked) { + keyring.unlockPair(pair.address); + } + + const signAtIndexGenerate = signAtIndex ? (isArray(signAtIndex) ? signAtIndex : [signAtIndex]) : [...(Array(psbt.inputCount) as number[])].map((_, i) => i); + let psptSignedTransaction: Psbt | null = null; + + // Sign the Psbt using the pair's bitcoin object + try { + psptSignedTransaction = pair.bitcoin.signTransaction(psbt, signAtIndexGenerate, allowedSighash); + } catch (e) { + if (transaction) { + transaction.emitterTransaction?.emit('error', { ...eventData, errors: [new TransactionError(BasicTxErrorType.INVALID_PARAMS, (e as Error).message)], id: transaction.id, extrinsicHash: transaction.id }); + } + + throw new Error((e as Error).message); + } + + if (!psptSignedTransaction) { + throw new Error('Unable to sign'); + } + + if (!broadcast) { + for (const index of signAtIndexGenerate) { + psptSignedTransaction.finalizeInput(index); + } + + return { + psbt: psptSignedTransaction.toHex() + }; + } + + if (!transaction) { + throw new BitcoinProviderError(BitcoinProviderErrorType.INTERNAL_ERROR); + } + + const { chain, emitterTransaction, id } = transaction; + + eventData = { + id, + errors: [], + warnings: [], + extrinsicHash: id + }; + + if (!emitterTransaction) { + throw new BitcoinProviderError(BitcoinProviderErrorType.INTERNAL_ERROR); + } + + const chainInfo = this.#chainService.getChainInfoByKey(chain); + + try { + psptSignedTransaction.finalizeAllInputs(); + } catch (e) { + emitterTransaction.emit('error', { ...eventData, errors: [new TransactionError(BasicTxErrorType.INVALID_PARAMS, (e as Error).message)] }); + throw new Error((e as Error).message); + } + + const hexTransaction = psptSignedTransaction.extractTransaction().toHex(); + + this.#transactionService.emitterEventTransaction(emitterTransaction, eventData, chainInfo.slug, hexTransaction); + const { promise, reject, resolve } = createPromiseHandler(); + + emitterTransaction.on('extrinsicHash', (data) => { + if (!data.extrinsicHash || !psptSignedTransaction) { + reject(BitcoinProviderErrorType.INTERNAL_ERROR); + } else { + resolve({ + psbt: psptSignedTransaction?.toHex(), + txid: data.extrinsicHash + }); + } + }); + + emitterTransaction.on('error', (error) => { + reject(error); + }); + + return promise; + } + + private async decorateResultBitcoin (t: T, request: ConfirmationDefinitionsBitcoin[T][0], result: ConfirmationDefinitionsBitcoin[T][1]) { + if (t === 'bitcoinSignatureRequest') { + result.payload = this.signMessageBitcoin(request as ConfirmationDefinitionsBitcoin['bitcoinSignatureRequest'][0]); + } else if (t === 'bitcoinSendTransactionRequest') { + result.payload = this.signTransactionBitcoin(request as ConfirmationDefinitionsBitcoin['bitcoinSendTransactionRequest'][0]); + } else if (t === 'bitcoinSignPsbtRequest') { + result.payload = await this.signPsbt(request as ConfirmationDefinitionsBitcoin['bitcoinSignPsbtRequest'][0]); + } else if (t === 'bitcoinSendTransactionRequestAfterConfirmation') { + result.payload = await this.signTransactionBitcoinWithPayload(request as ConfirmationDefinitionsBitcoin['bitcoinSendTransactionRequestAfterConfirmation'][0]); + } + + if (t === 'bitcoinSignatureRequest' || t === 'bitcoinSendTransactionRequest' || t === 'bitcoinSignPsbtRequest' || t === 'bitcoinSendTransactionRequestAfterConfirmation') { + const isAlwaysRequired = await this.#requestService.settingService.isAlwaysRequired; + + if (isAlwaysRequired) { + this.#requestService.keyringService.lock(); + } + } + } + + public async completeConfirmationBitcoin (request: RequestConfirmationCompleteBitcoin): Promise { + const confirmations = this.confirmationsQueueSubjectBitcoin.getValue(); + + for (const ct in request) { + const type = ct as ConfirmationTypeBitcoin; + const result = request[type] as ConfirmationDefinitionsBitcoin[typeof type][1]; + + const { id, isApproved } = result; + const { resolver, validator } = this.confirmationsPromiseMap[id]; + const confirmation = confirmations[type][id]; + + if (!resolver || !confirmation) { + this.#logger.error(t('Unable to proceed. Please try again'), type, id); + throw new Error('Unable to proceed. Please try again'); + } + + if (isApproved) { + try { + // Fill signature for some special type + await this.decorateResultBitcoin(type, confirmation, result); + const error = validator && validator(result); + + if (error) { + resolver.reject(error); + } + } catch (e) { + resolver.reject(e as Error); + } + } + + // Delete confirmations from queue + delete this.confirmationsPromiseMap[id]; + delete confirmations[type][id]; + this.confirmationsQueueSubjectBitcoin.next(confirmations); + + // Update icon, and close queue + this.#requestService.updateIconV2(this.#requestService.numAllRequests === 0); + resolver.resolve(result); + } + + return true; + } + + public resetWallet () { + const confirmations = this.confirmationsQueueSubjectBitcoin.getValue(); + + for (const [type, requests] of Object.entries(confirmations)) { + for (const confirmation of Object.values(requests)) { + const { id } = confirmation as ConfirmationRequestBase; + const { resolver } = this.confirmationsPromiseMap[id]; + + if (!resolver || !confirmation) { + console.error('Not found confirmation', type, id); + } else { + resolver.reject(new Error('Reset wallet')); + } + + delete this.confirmationsPromiseMap[id]; + delete confirmations[type as ConfirmationTypeBitcoin][id]; + } + } + + this.confirmationsQueueSubjectBitcoin.next(confirmations); + } +} diff --git a/packages/extension-base/src/services/request-service/index.ts b/packages/extension-base/src/services/request-service/index.ts index c1dc1be2122..5a419d0350b 100644 --- a/packages/extension-base/src/services/request-service/index.ts +++ b/packages/extension-base/src/services/request-service/index.ts @@ -1,12 +1,15 @@ // Copyright 2019-2022 @subwallet/extension-base authors & contributors // SPDX-License-Identifier: Apache-2.0 -import { AuthRequestV2, ConfirmationDefinitions, ConfirmationDefinitionsCardano, ConfirmationDefinitionsTon, ConfirmationsQueue, ConfirmationsQueueCardano, ConfirmationsQueueItemOptions, ConfirmationsQueueTon, ConfirmationType, ConfirmationTypeCardano, ConfirmationTypeTon, RequestConfirmationComplete, RequestConfirmationCompleteCardano, RequestConfirmationCompleteTon } from '@subwallet/extension-base/background/KoniTypes'; +import { AuthRequestV2, ConfirmationDefinitions, ConfirmationDefinitionsBitcoin, ConfirmationDefinitionsCardano, ConfirmationDefinitionsTon, ConfirmationsQueue, ConfirmationsQueueBitcoin, ConfirmationsQueueCardano, ConfirmationsQueueItemOptions, ConfirmationsQueueTon, ConfirmationType, ConfirmationTypeBitcoin, ConfirmationTypeCardano, ConfirmationTypeTon, RequestConfirmationComplete, RequestConfirmationCompleteBitcoin, RequestConfirmationCompleteCardano, RequestConfirmationCompleteTon } from '@subwallet/extension-base/background/KoniTypes'; import { AccountAuthType, AuthorizeRequest, MetadataRequest, RequestAuthorizeTab, RequestSign, ResponseSigning, SigningRequest } from '@subwallet/extension-base/background/types'; import { ChainService } from '@subwallet/extension-base/services/chain-service'; +import FeeService from '@subwallet/extension-base/services/fee-service/service'; import { KeyringService } from '@subwallet/extension-base/services/keyring-service'; +import BitcoinRequestHandler from '@subwallet/extension-base/services/request-service/handler/BitcoinRequestHandler'; import CardanoRequestHandler from '@subwallet/extension-base/services/request-service/handler/CardanoRequestHandler'; import SettingService from '@subwallet/extension-base/services/setting-service/SettingService'; +import TransactionService from '@subwallet/extension-base/services/transaction-service'; import { WalletConnectNotSupportRequest, WalletConnectSessionRequest } from '@subwallet/extension-base/services/wallet-connect-service/types'; import { MetadataDef } from '@subwallet/extension-inject/types'; import { BehaviorSubject } from 'rxjs'; @@ -27,13 +30,14 @@ export default class RequestService { readonly #authRequestHandler: AuthRequestHandler; readonly #substrateRequestHandler: SubstrateRequestHandler; readonly #evmRequestHandler: EvmRequestHandler; + readonly #bitcoinRequestHandler: BitcoinRequestHandler; readonly #tonRequestHandler: TonRequestHandler; readonly #cardanoRequestHandler: CardanoRequestHandler; readonly #connectWCRequestHandler: ConnectWCRequestHandler; readonly #notSupportWCRequestHandler: NotSupportWCRequestHandler; // Common - constructor (chainService: ChainService, settingService: SettingService, keyringService: KeyringService) { + constructor (chainService: ChainService, settingService: SettingService, keyringService: KeyringService, feeService: FeeService, transactionService: TransactionService) { this.#chainService = chainService; this.settingService = settingService; this.keyringService = keyringService; @@ -44,6 +48,7 @@ export default class RequestService { this.#evmRequestHandler = new EvmRequestHandler(this); this.#tonRequestHandler = new TonRequestHandler(this); this.#cardanoRequestHandler = new CardanoRequestHandler(this); + this.#bitcoinRequestHandler = new BitcoinRequestHandler(this, this.#chainService, feeService, transactionService); this.#connectWCRequestHandler = new ConnectWCRequestHandler(this); this.#notSupportWCRequestHandler = new NotSupportWCRequestHandler(this); @@ -52,7 +57,7 @@ export default class RequestService { } public get numAllRequests () { - return this.allSubstrateRequests.length + this.numEvmRequests + this.numTonRequests + this.numCardanoRequests; + return this.allSubstrateRequests.length + this.numEvmRequests + this.numTonRequests + this.numCardanoRequests + this.numBitcoinRequests; } public updateIconV2 (shouldClose?: boolean): void { @@ -185,6 +190,10 @@ export default class RequestService { return this.#cardanoRequestHandler.numCardanoRequests; } + public get numBitcoinRequests (): number { + return this.#bitcoinRequestHandler.numBitcoinRequests; + } + public get confirmationsQueueSubject (): BehaviorSubject { return this.#evmRequestHandler.getConfirmationsQueueSubject(); } @@ -260,6 +269,37 @@ export default class RequestService { return this.#evmRequestHandler.updateConfirmation(id, type, payload, options, validator); } + // Bitcoin requests + + public get confirmationsQueueSubjectBitcoin (): BehaviorSubject { + return this.#bitcoinRequestHandler.getConfirmationsQueueSubjectBitcoin(); + } + + public addConfirmationBitcoin ( + id: string, + url: string, + type: CT, + payload: ConfirmationDefinitionsBitcoin[CT][0]['payload'], + options: ConfirmationsQueueItemOptions = {}, + validator?: (input: ConfirmationDefinitionsBitcoin[CT][1]) => Error | undefined + ): Promise { + return this.#bitcoinRequestHandler.addConfirmationBitcoin(id, url, type, payload, options, validator); + } + + public async completeConfirmationBitcoin (request: RequestConfirmationCompleteBitcoin): Promise { + return await this.#bitcoinRequestHandler.completeConfirmationBitcoin(request); + } + + public updateConfirmationBitcoin ( + id: string, + type: CT, + payload: ConfirmationDefinitionsBitcoin[CT][0]['payload'], + options: ConfirmationsQueueItemOptions = {}, + validator?: (input: ConfirmationDefinitionsBitcoin[CT][1]) => Error | undefined + ) { + return this.#bitcoinRequestHandler.updateConfirmationBitcoin(id, type, payload, options, validator); + } + // WalletConnect Connect requests public getConnectWCRequest (id: string) { return this.#connectWCRequestHandler.getConnectWCRequest(id); @@ -304,7 +344,7 @@ export default class RequestService { // General methods public get numRequests (): number { - return this.numMetaRequests + this.numAuthRequests + this.numSubstrateRequests + this.numEvmRequests + this.numConnectWCRequests + this.numNotSupportWCRequests + this.numTonRequests + this.numCardanoRequests; + return this.numMetaRequests + this.numAuthRequests + this.numSubstrateRequests + this.numEvmRequests + this.numConnectWCRequests + this.numNotSupportWCRequests + this.numTonRequests + this.numCardanoRequests + this.numBitcoinRequests; } public resetWallet (): void { @@ -313,6 +353,7 @@ export default class RequestService { this.#evmRequestHandler.resetWallet(); this.#tonRequestHandler.resetWallet(); this.#cardanoRequestHandler.resetWallet(); + this.#bitcoinRequestHandler.resetWallet(); this.#metadataRequestHandler.resetWallet(); this.#connectWCRequestHandler.resetWallet(); this.#notSupportWCRequestHandler.resetWallet(); diff --git a/packages/extension-base/src/services/rune-service/index.ts b/packages/extension-base/src/services/rune-service/index.ts new file mode 100644 index 00000000000..d932dc31713 --- /dev/null +++ b/packages/extension-base/src/services/rune-service/index.ts @@ -0,0 +1,126 @@ +// Copyright 2019-2022 @subwallet/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { SWError } from '@subwallet/extension-base/background/errors/SWError'; +import { _BTC_SERVICE_TOKEN } from '@subwallet/extension-base/services/chain-service/constants'; +import { RuneMetadata, RunesCollectionInfoResponse, RunesInfoByAddressFetchedData, RuneTxsResponse, RuneUtxoResponse } from '@subwallet/extension-base/services/chain-service/handler/bitcoin/strategy/types'; +import { OBResponse, OBRuneResponse } from '@subwallet/extension-base/services/chain-service/types'; +import { BaseApiRequestStrategy } from '@subwallet/extension-base/strategy/api-request-strategy'; +import { BaseApiRequestContext } from '@subwallet/extension-base/strategy/api-request-strategy/context/base'; +import { getRequest } from '@subwallet/extension-base/strategy/api-request-strategy/utils'; + +const BITCOIN_API_URL = 'https://btc-api.koni.studio'; +const BITCOIN_API_URL_TEST = 'https://api-testnet.openbit.app'; + +export class RunesService extends BaseApiRequestStrategy { + baseUrl: string; + + private constructor (url: string) { + const context = new BaseApiRequestContext(); + + super(context); + + this.baseUrl = url; + } + + private headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${_BTC_SERVICE_TOKEN}` + }; + + isRateLimited (): boolean { + return false; + } + + getUrl (path: string): string { + return `${this.baseUrl}/${path}`; + } + + getAddressRunesInfo (address: string, params: Record): Promise { + return this.addRequest(async () => { + const _rs = await getRequest(this.getUrl(`rune/address/${address}`), params, this.headers); + const rs = await _rs.json() as OBResponse; + + if (rs.status_code !== 200) { + throw new SWError('RuneScanService.getAddressRunesInfo', rs.message); + } + + return rs.result; + }, 1); + } + + // * Deprecated + getRuneCollectionsByBatch (params: Record): Promise { + return this.addRequest(async () => { + const url = this.getUrl('rune'); + const rs = await getRequest(url, params); + + if (rs.status !== 200) { + throw new SWError('RuneScanService.getRuneCollectionsByBatch', await rs.text()); + } + + return (await rs.json()) as RunesCollectionInfoResponse; + }, 1); + } + + // * Deprecated + getAddressRuneTxs (address: string, params: Record): Promise { + return this.addRequest(async () => { + const url = this.getUrl(`address/${address}/txs`); + const rs = await getRequest(url, params); + + if (rs.status !== 200) { + throw new SWError('RuneScanService.getAddressRuneTxs', await rs.text()); + } + + return (await rs.json()) as RuneTxsResponse; + }, 0); + } + + getRuneMetadata (runeid: string): Promise { + return this.addRequest(async () => { + const _rs = await getRequest(this.getUrl(`rune/metadata/${runeid}`), undefined, this.headers); + const rs = await _rs.json() as OBResponse; + + if (rs.status_code !== 200) { + throw new SWError('RuneScanService.getRuneMetadata', rs.message); + } + + return rs.result; + }, 0); + } + + getAddressRuneUtxos (address: string): Promise { + return this.addRequest(async () => { + const _rs = await getRequest(this.getUrl(`rune/address/${address}/rune/utxo`), undefined, this.headers); + + const rs = await _rs.json() as OBRuneResponse; + + if (rs.status_code !== 200) { + throw new SWError('RuneScanService.getAddressRuneUtxos', rs.message); + } + + return rs.result; + }, 0); + } + + // Singleton + private static mainnet: RunesService; + private static testnet: RunesService; + + public static getInstance (isTestnet = false) { + if (isTestnet) { + if (!RunesService.testnet) { + RunesService.testnet = new RunesService(BITCOIN_API_URL_TEST); + } + + return RunesService.testnet; + } else { + if (!RunesService.mainnet) { + RunesService.mainnet = new RunesService(BITCOIN_API_URL); + } + + return RunesService.mainnet; + } + } +} diff --git a/packages/extension-base/src/services/transaction-service/helpers/index.ts b/packages/extension-base/src/services/transaction-service/helpers/index.ts index 61ffdeafa3d..b77a82abc17 100644 --- a/packages/extension-base/src/services/transaction-service/helpers/index.ts +++ b/packages/extension-base/src/services/transaction-service/helpers/index.ts @@ -5,7 +5,8 @@ import { _ChainInfo } from '@subwallet/chain-list/types'; import { ExtrinsicType } from '@subwallet/extension-base/background/KoniTypes'; import { CardanoTransactionConfig } from '@subwallet/extension-base/services/balance-service/transfer/cardano-transfer'; import { TonTransactionConfig } from '@subwallet/extension-base/services/balance-service/transfer/ton-transfer'; -import { SWTransactionBase } from '@subwallet/extension-base/services/transaction-service/types'; +import { SWTransaction, SWTransactionBase } from '@subwallet/extension-base/services/transaction-service/types'; +import { Psbt } from 'bitcoinjs-lib'; import { SubmittableExtrinsic } from '@polkadot/api/promise/types'; @@ -36,6 +37,11 @@ export const isCardanoTransaction = (tx: SWTransactionBase['transaction']): tx i return cardanoTransactionConfig.cardanoPayload !== null && cardanoTransactionConfig.cardanoPayload !== undefined; }; +// TODO: [Review] this function +export const isBitcoinTransaction = (tx: SWTransaction['transaction']): tx is Psbt => { + return 'data' in tx && Array.isArray((tx as Psbt).data.inputs); +}; + const typeName = (type: SWTransactionBase['extrinsicType']) => { switch (type) { case ExtrinsicType.TRANSFER_BALANCE: diff --git a/packages/extension-base/src/services/transaction-service/index.ts b/packages/extension-base/src/services/transaction-service/index.ts index f24bfeff4c6..b19b81d33ec 100644 --- a/packages/extension-base/src/services/transaction-service/index.ts +++ b/packages/extension-base/src/services/transaction-service/index.ts @@ -3,26 +3,23 @@ import { EvmProviderError } from '@subwallet/extension-base/background/errors/EvmProviderError'; import { TransactionError } from '@subwallet/extension-base/background/errors/TransactionError'; -import { AmountData, ChainType, EvmProviderErrorType, EvmSendTransactionRequest, EvmSignatureRequest, ExtrinsicStatus, ExtrinsicType, NotificationType, TransactionAdditionalInfo, TransactionDirection, TransactionHistoryItem } from '@subwallet/extension-base/background/KoniTypes'; +import { AmountData, BitcoinSignatureRequest, ChainType, EvmProviderErrorType, EvmSendTransactionRequest, EvmSignatureRequest, ExtrinsicStatus, ExtrinsicType, NotificationType, TransactionAdditionalInfo, TransactionDirection, TransactionHistoryItem } from '@subwallet/extension-base/background/KoniTypes'; import { _SUPPORT_TOKEN_PAY_FEE_GROUP, ALL_ACCOUNT_KEY, fetchBlockedConfigObjects, fetchLatestBlockedActionsAndFeatures, getPassConfigId } from '@subwallet/extension-base/constants'; import { checkBalanceWithTransactionFee, checkSigningAccountForTransaction, checkSupportForAction, checkSupportForFeature, checkSupportForTransaction, estimateFeeForTransaction } from '@subwallet/extension-base/core/logic-validation/transfer'; import KoniState from '@subwallet/extension-base/koni/background/handlers/State'; import { cellToBase64Str, externalMessage, getTransferCellPromise } from '@subwallet/extension-base/services/balance-service/helpers/subscribe/ton/utils'; import { CardanoTransactionConfig } from '@subwallet/extension-base/services/balance-service/transfer/cardano-transfer'; import { TonTransactionConfig } from '@subwallet/extension-base/services/balance-service/transfer/ton-transfer'; -import { ChainService } from '@subwallet/extension-base/services/chain-service'; import { _getAssetDecimals, _getAssetSymbol, _getChainNativeTokenBasicInfo, _getEvmChainId, _isChainEvmCompatible, _isNativeTokenBySlug } from '@subwallet/extension-base/services/chain-service/utils'; -import { EventService } from '@subwallet/extension-base/services/event-service'; -import { HistoryService } from '@subwallet/extension-base/services/history-service'; import { ClaimAvailBridgeNotificationMetadata } from '@subwallet/extension-base/services/inapp-notification-service/interfaces'; import { EXTENSION_REQUEST_URL } from '@subwallet/extension-base/services/request-service/constants'; import { TRANSACTION_TIMEOUT } from '@subwallet/extension-base/services/transaction-service/constants'; import { parseLiquidStakingEvents, parseLiquidStakingFastUnstakeEvents, parseTransferEventLogs, parseXcmEventLogs } from '@subwallet/extension-base/services/transaction-service/event-parser'; -import { getBaseTransactionInfo, getTransactionId, isCardanoTransaction, isSubstrateTransaction, isTonTransaction } from '@subwallet/extension-base/services/transaction-service/helpers'; +import { getBaseTransactionInfo, getTransactionId, isBitcoinTransaction, isCardanoTransaction, isSubstrateTransaction, isTonTransaction } from '@subwallet/extension-base/services/transaction-service/helpers'; import { OptionalSWTransaction, SWDutchTransaction, SWDutchTransactionInput, SWPermitTransaction, SWPermitTransactionInput, SWTransaction, SWTransactionBase, SWTransactionInput, SWTransactionResponse, TransactionEmitter, TransactionEventMap, TransactionEventResponse, ValidateTransactionResponseInput } from '@subwallet/extension-base/services/transaction-service/types'; import { getExplorerLink, parseTransactionData } from '@subwallet/extension-base/services/transaction-service/utils'; import { isWalletConnectRequest } from '@subwallet/extension-base/services/wallet-connect-service/helpers'; -import { AccountJson, BaseStepType, BasicTxErrorType, BasicTxWarningCode, BriefProcessStep, EvmFeeInfo, LeavePoolAdditionalData, PermitSwapData, ProcessStep, ProcessTransactionData, RequestStakePoolingBonding, RequestYieldStepSubmit, SpecialYieldPoolInfo, StepStatus, SubmitJoinNominationPool, SubstrateTipInfo, TransactionErrorType, Web3Transaction, YieldPoolType } from '@subwallet/extension-base/types'; +import { AccountJson, BaseStepType, BasicTxErrorType, BasicTxWarningCode, BriefProcessStep, LeavePoolAdditionalData, PermitSwapData, ProcessStep, ProcessTransactionData, RequestStakePoolingBonding, RequestYieldStepSubmit, SpecialYieldPoolInfo, StepStatus, SubmitJoinNominationPool, SubstrateTipInfo, TransactionErrorType, Web3Transaction, YieldPoolType } from '@subwallet/extension-base/types'; import { anyNumberToBN, pairToAccount, reformatAddress } from '@subwallet/extension-base/utils'; import { mergeTransactionAndSignature } from '@subwallet/extension-base/utils/eth/mergeTransactionAndSignature'; import { isContractAddress, parseContractInput } from '@subwallet/extension-base/utils/eth/parseTransaction'; @@ -31,6 +28,7 @@ import { BN_ZERO } from '@subwallet/extension-base/utils/number'; import keyring from '@subwallet/ui-keyring'; import { Cell } from '@ton/core'; import BigN from 'bignumber.js'; +import { Psbt } from 'bitcoinjs-lib'; import { addHexPrefix } from 'ethereumjs-util'; import { ethers, TransactionLike } from 'ethers'; import EventEmitter from 'eventemitter3'; @@ -45,14 +43,8 @@ import { SignerPayloadJSON } from '@polkadot/types/types/extrinsic'; import { hexToU8a, isHex } from '@polkadot/util'; import { HexString } from '@polkadot/util/types'; -import NotificationService from '../notification-service/NotificationService'; - export default class TransactionService { private readonly state: KoniState; - private readonly eventService: EventService; - private readonly historyService: HistoryService; - private readonly notificationService: NotificationService; - private readonly chainService: ChainService; private readonly watchTransactionSubscribes: Record> = {}; @@ -69,10 +61,6 @@ export default class TransactionService { constructor (state: KoniState) { this.state = state; - this.eventService = state.eventService; - this.historyService = state.historyService; - this.notificationService = state.notificationService; - this.chainService = state.chainService; } private get allTransactions (): SWTransactionBase[] { @@ -107,7 +95,7 @@ export default class TransactionService { warnings: transactionInput.warnings || [], processId: transactionInput.step?.processId }; - const { additionalValidator, address, chain, extrinsicType } = validationResponse; + const { additionalValidator, address, chain, chainType, extrinsicType } = validationResponse; const chainInfo = this.state.chainService.getChainInfoByKey(chain); const blockedConfigObjects = await fetchBlockedConfigObjects(); @@ -141,23 +129,25 @@ export default class TransactionService { const evmApi = this.state.chainService.getEvmApi(chainInfo.slug); const tonApi = this.state.chainService.getTonApi(chainInfo.slug); const cardanoApi = this.state.chainService.getCardanoApi(chainInfo.slug); + const bitcoinApi = this.state.chainService.getBitcoinApi(chainInfo.slug); // todo: should split into isEvmTx && isNoEvmApi. Because other chains type also has no Evm Api. Same to all blockchain. // todo: refactor check evmTransaction. - const isNoEvmApi = transaction && !isSubstrateTransaction(transaction) && !isTonTransaction(transaction) && !isCardanoTransaction(transaction) && !evmApi; + const isNoEvmApi = transaction && !isSubstrateTransaction(transaction) && !isTonTransaction(transaction) && !isCardanoTransaction(transaction) && !isBitcoinTransaction(transaction) && !evmApi; const isNoTonApi = transaction && isTonTransaction(transaction) && !tonApi; const isNoCardanoApi = transaction && isCardanoTransaction(transaction) && !cardanoApi; + const isNoBitcoinApi = transaction && isBitcoinTransaction(transaction) && !bitcoinApi; - if (isNoEvmApi || isNoTonApi || isNoCardanoApi) { + if (isNoEvmApi || isNoTonApi || isNoCardanoApi || isNoBitcoinApi) { validationResponse.errors.push(new TransactionError(BasicTxErrorType.CHAIN_DISCONNECTED, undefined)); } // Estimate fee for transaction const id = getId(); - const feeInfo = await this.state.feeService.subscribeChainFee(id, chain, 'evm') as EvmFeeInfo; + const feeInfo = await this.state.feeService.subscribeChainFee(id, chain, chainType); const nativeTokenInfo = this.state.chainService.getNativeTokenInfo(chain); const tokenPayFeeSlug = transactionInput.tokenPayFeeSlug; const isNonNativeTokenPayFee = tokenPayFeeSlug && !_isNativeTokenBySlug(tokenPayFeeSlug); - const nonNativeTokenPayFeeInfo = isNonNativeTokenPayFee ? this.chainService.getAssetBySlug(tokenPayFeeSlug) : undefined; + const nonNativeTokenPayFeeInfo = isNonNativeTokenPayFee ? this.state.chainService.getAssetBySlug(tokenPayFeeSlug) : undefined; const priceMap = (await this.state.priceService.getPrice()).priceMap; validationResponse.estimateFee = await estimateFeeForTransaction(validationResponse, transaction, chainInfo, evmApi, substrateApi, priceMap, feeInfo, nativeTokenInfo, nonNativeTokenPayFeeInfo, transactionInput.isTransferLocalTokenAndPayThatTokenAsFee); @@ -165,6 +155,7 @@ export default class TransactionService { const chainInfoMap = this.state.chainService.getChainInfoMap(); // Check account signing transaction + checkSigningAccountForTransaction(validationResponse, chainInfoMap); const nativeTokenAvailable = await this.state.balanceService.getTransferableBalance(address, chain, nativeTokenInfo.slug, extrinsicType); @@ -243,7 +234,7 @@ export default class TransactionService { private fillTransactionDefaultInfo (transaction: SWTransactionInput): SWTransaction { const isInternal = !transaction.url; - const transactionId = getTransactionId(transaction.chainType, transaction.chain, isInternal, isWalletConnectRequest(transaction.id)); + const transactionId = transaction.id || getTransactionId(transaction.chainType, transaction.chain, isInternal, isWalletConnectRequest(transaction.id)); return { ...transaction, @@ -503,7 +494,9 @@ export default class TransactionService { ? this.signAndSendEvmTransaction(transaction) : transaction.chainType === 'cardano' ? this.signAndSendCardanoTransaction(transaction) - : this.signAndSendTonTransaction(transaction)); + : transaction.chainType === 'ton' + ? this.signAndSendTonTransaction(transaction) + : this.signAndSendBitcoinTransaction(transaction)); const { eventsHandler, step } = transaction; @@ -740,6 +733,7 @@ export default class TransactionService { extrinsicHash: transaction.extrinsicHash, time: transaction.createdAt, fee: transaction.estimateFee, + blockTime: undefined, blockNumber: 0, // Will be added in next step blockHash: '', // Will be added in next step nonce: nonce ?? 0, @@ -1114,6 +1108,30 @@ export default class TransactionService { ].includes(transaction.extrinsicType)) { this.handlePostEarningTransaction(id); } + + // Trigger balance update for Bitcoin transactions after receiving extrinsicHash + if (ExtrinsicType.TRANSFER_BALANCE && transaction.chainType === 'bitcoin') { + const balanceService = this.state.balanceService; + const inputData = parseTransactionData(transaction.data); + + try { + const sender = keyring.getPair(inputData.from); + + balanceService.refreshBalanceForAddress(sender.address, transaction.chain, inputData.tokenSlug, transaction.extrinsicType) + .catch((error) => console.error('Failed to run balance subscription:', error)); + } catch (e) { + console.error(e); + } + + try { + const recipient = keyring.getPair(inputData.to); + + balanceService.refreshBalanceForAddress(recipient.address, transaction.chain, inputData.tokenSlug, transaction.extrinsicType) + .catch((error) => console.error('Failed to run balance subscription:', error)); + } catch (e) { + console.error(e); + } + } } private handlePostProcessing (id: string) { // must be done after success/failure to make sure the transaction is finalized @@ -1153,7 +1171,7 @@ export default class TransactionService { } } - private onSuccess ({ blockHash, blockNumber, extrinsicHash, id }: TransactionEventResponse) { + private onSuccess ({ blockHash, blockNumber, blockTime, extrinsicHash, id }: TransactionEventResponse) { const transaction = this.getTransaction(id); this.updateTransaction(id, { status: ExtrinsicStatus.SUCCESS, extrinsicHash }); @@ -1163,7 +1181,8 @@ export default class TransactionService { extrinsicHash, status: ExtrinsicStatus.SUCCESS, blockNumber: blockNumber || 0, - blockHash: blockHash || '' + blockHash: blockHash || '', + blockTime }).catch(console.error); const info = isHex(extrinsicHash) ? extrinsicHash : getBaseTransactionInfo(transaction, this.state.chainService.getChainInfoMap()); @@ -1215,16 +1234,16 @@ export default class TransactionService { if (transaction) { this.updateTransaction(id, { status: nextStatus, errors, extrinsicHash }); - this.historyService.updateHistoryByExtrinsicHash(transaction.extrinsicHash, { + this.state.historyService.updateHistoryByExtrinsicHash(transaction.extrinsicHash, { extrinsicHash: extrinsicHash || transaction.extrinsicHash, status: nextStatus, blockNumber: blockNumber || 0, blockHash: blockHash || '' }).catch(console.error); - const info = isHex(transaction?.extrinsicHash) ? transaction?.extrinsicHash : getBaseTransactionInfo(transaction, this.chainService.getChainInfoMap()); + const info = isHex(transaction?.extrinsicHash) ? transaction?.extrinsicHash : getBaseTransactionInfo(transaction, this.state.chainService.getChainInfoMap()); - this.notificationService.notify({ + this.state.notificationService.notify({ type: NotificationType.ERROR, title: t('Transaction timed out'), message: t('Transaction {{info}} timed out', { replace: { info } }), @@ -1233,7 +1252,7 @@ export default class TransactionService { }); } - this.eventService.emit('transaction.timeout', transaction); + this.state.eventService.emit('transaction.timeout', transaction); } public generateHashPayload (chain: string, transaction: TransactionConfig): HexString { @@ -1917,6 +1936,111 @@ export default class TransactionService { return emitter; } + public emitterEventTransaction = (emitter: TransactionEmitter, eventData: TransactionEventResponse, chain: string, payload: string) => { + // Emit signed event + emitter.emit('signed', eventData); + // Add start info + emitter.emit('send', eventData); + + const event = this.state.chainService.getBitcoinApi(chain).api.sendRawTransaction(payload); + + event.on('extrinsicHash', (txHash) => { + eventData.extrinsicHash = txHash; + emitter.emit('extrinsicHash', eventData); + }); + + event.on('success', (transactionStatus) => { + console.log(transactionStatus); + eventData.blockHash = transactionStatus.block_hash || undefined; + eventData.blockNumber = transactionStatus.block_height || undefined; + eventData.blockTime = transactionStatus.block_time ? (transactionStatus.block_time * 1000) : undefined; + emitter.emit('success', eventData); + }); + + event.on('error', (error) => { + eventData.errors.push(new TransactionError(BasicTxErrorType.UNABLE_TO_SEND, error)); + emitter.emit('error', eventData); + }); + }; + + private signAndSendBitcoinTransaction ({ address, chain, id, transaction, url }: SWTransaction): TransactionEmitter { + const tx = transaction as Psbt; + // const bitcoinApi = this.state.chainService.getBitcoinApi(chain); + // const chainInfo = this.state.chainService.getChainInfoByKey(chain); + + const accountPair = keyring.getPair(address); + const account: AccountJson = pairToAccount(accountPair); + + const payload: BitcoinSignatureRequest = { + payload: undefined, + payloadJson: undefined, + account, + canSign: true, + hashPayload: tx.toHex(), + id + }; + + const emitter = new EventEmitter(); + + const eventData: TransactionEventResponse = { + id, + errors: [], + warnings: [], + extrinsicHash: id + }; + + const isInjected = !!account.isInjected; + // const isExternal = !!account.isExternal; + + if (isInjected) { + throw new TransactionError(BasicTxErrorType.UNSUPPORTED); + } else { + this.state.requestService.addConfirmationBitcoin(id, url || EXTENSION_REQUEST_URL, 'bitcoinSendTransactionRequest', payload, {}) + .then(({ isApproved, payload }) => { + if (isApproved) { + if (!payload) { + throw new Error('Bad signature'); + } + + // Emit signed event + emitter.emit('signed', eventData); + // Add start info + emitter.emit('send', eventData); + const bitcoinApi = this.state.chainService.getBitcoinApi(chain); + const event = bitcoinApi.api.sendRawTransaction(payload); + + event.on('extrinsicHash', (txHash) => { + eventData.extrinsicHash = txHash; + emitter.emit('extrinsicHash', eventData); + }); + + event.on('success', (transactionStatus) => { + eventData.blockHash = transactionStatus.block_hash || undefined; + eventData.blockNumber = transactionStatus.block_height || undefined; + eventData.blockTime = transactionStatus.block_time ? (transactionStatus.block_time * 1000) : undefined; + emitter.emit('success', eventData); + }); + + event.on('error', (error) => { + eventData.errors.push(new TransactionError(BasicTxErrorType.UNABLE_TO_SEND, error)); + emitter.emit('error', eventData); + }); + } else { + this.removeTransaction(id); + eventData.errors.push(new TransactionError(BasicTxErrorType.USER_REJECT_REQUEST)); + emitter.emit('error', eventData); + } + }) + .catch((e: Error) => { + this.removeTransaction(id); + eventData.errors.push(new TransactionError(BasicTxErrorType.UNABLE_TO_SIGN, e.message)); + emitter.emit('error', eventData); + }); + } + + return emitter; + } + private handleTransactionTimeout (emitter: EventEmitter, eventData: TransactionEventResponse): void { const timeout = setTimeout(() => { const transaction = this.getTransaction(eventData.id); diff --git a/packages/extension-base/src/services/transaction-service/types.ts b/packages/extension-base/src/services/transaction-service/types.ts index 8142624e8b5..8099937034b 100644 --- a/packages/extension-base/src/services/transaction-service/types.ts +++ b/packages/extension-base/src/services/transaction-service/types.ts @@ -6,13 +6,14 @@ import { SignTypedDataMessageV3V4 } from '@subwallet/extension-base/core/logic-v import { TonTransactionConfig } from '@subwallet/extension-base/services/balance-service/transfer/ton-transfer'; import { UniswapOrderInfo } from '@subwallet/extension-base/services/swap-service/handler/uniswap-handler'; import { BaseRequestSign, BriefProcessStep, ProcessTransactionData, TransactionFee } from '@subwallet/extension-base/types'; +import { Psbt } from 'bitcoinjs-lib'; import EventEmitter from 'eventemitter3'; import { TransactionConfig } from 'web3-core'; import { SubmittableExtrinsic } from '@polkadot/api/promise/types'; import { EventRecord } from '@polkadot/types/interfaces'; -export interface SWTransactionBase extends ValidateTransactionResponse, Partial>, TransactionFee { +export interface SWTransactionBase extends ValidateTransactionResponse, Partial>, TransactionFee, SWTransactionEmitter { id: string; url?: string; isInternal: boolean, @@ -37,7 +38,7 @@ export interface SWTransactionBase extends ValidateTransactionResponse, Partial< } export interface SWTransaction extends SWTransactionBase { - transaction: SubmittableExtrinsic | TransactionConfig | TonTransactionConfig; + transaction: SubmittableExtrinsic | TransactionConfig | TonTransactionConfig | Psbt; } export interface SWPermitTransaction extends SWTransactionBase { @@ -55,6 +56,10 @@ export interface SWTransactionResult extends Omit & Partial>; @@ -82,6 +87,12 @@ export type SWTransactionResponse = SwInputBase & Pick; @@ -95,6 +106,7 @@ export interface TransactionEventResponse extends ValidateTransactionResponse { eventLogs?: EventRecord[], nonce?: number, startBlock?: number, + blockTime?: number, } export interface TransactionEventMap { send: (response: TransactionEventResponse) => void; diff --git a/packages/extension-base/src/services/transaction-service/utils.ts b/packages/extension-base/src/services/transaction-service/utils.ts index 622761908fc..00350cfef34 100644 --- a/packages/extension-base/src/services/transaction-service/utils.ts +++ b/packages/extension-base/src/services/transaction-service/utils.ts @@ -3,7 +3,7 @@ import { _ChainInfo } from '@subwallet/chain-list/types'; import { ExtrinsicDataTypeMap, ExtrinsicsDataResponse, ExtrinsicType } from '@subwallet/extension-base/background/KoniTypes'; -import { _getBlockExplorerFromChain, _isChainTestNet, _isPureCardanoChain, _isPureEvmChain } from '@subwallet/extension-base/services/chain-service/utils'; +import { _getBlockExplorerFromChain, _isChainTestNet, _isPureBitcoinChain, _isPureCardanoChain, _isPureEvmChain } from '@subwallet/extension-base/services/chain-service/utils'; import { CHAIN_FLIP_MAINNET_EXPLORER, CHAIN_FLIP_TESTNET_EXPLORER, SIMPLE_SWAP_EXPLORER } from '@subwallet/extension-base/services/swap-service/utils'; import { ChainflipSwapTxData, SimpleSwapTxData } from '@subwallet/extension-base/types/swap'; import { SWApiResponse } from '@subwallet/subwallet-api-sdk/types'; @@ -55,7 +55,7 @@ function getBlockExplorerAccountRoute (explorerLink: string) { } function getBlockExplorerTxRoute (chainInfo: _ChainInfo) { - if (_isPureEvmChain(chainInfo)) { + if (_isPureEvmChain(chainInfo) || _isPureBitcoinChain(chainInfo)) { return 'tx'; } diff --git a/packages/extension-base/src/strategy/api-request-strategy/context/base.ts b/packages/extension-base/src/strategy/api-request-strategy/context/base.ts new file mode 100644 index 00000000000..914fadabafa --- /dev/null +++ b/packages/extension-base/src/strategy/api-request-strategy/context/base.ts @@ -0,0 +1,30 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +import { ApiRequestContext } from '@subwallet/extension-base/strategy/api-request-strategy/types'; + +export class BaseApiRequestContext implements ApiRequestContext { + callRate = 2; // limit per interval check + limitRate = 2; // max rate per interval check + intervalCheck = 1000; // interval check in ms + maxRetry = 9; // interval check in ms + private rollbackRateTime = 30 * 1000; // rollback rate time in ms + private timeoutRollbackRate: NodeJS.Timeout | undefined = undefined; + + constructor (options?: {limitRate?: number, intervalCheck?: number, maxRetry?: number}) { + this.callRate = options?.limitRate || this.callRate; + this.limitRate = options?.limitRate || this.limitRate; + this.intervalCheck = options?.intervalCheck || this.intervalCheck; + this.maxRetry = options?.maxRetry || this.maxRetry; + } + + reduceLimitRate () { + clearTimeout(this.timeoutRollbackRate); + + this.callRate = Math.ceil(this.limitRate / 2); + + this.timeoutRollbackRate = setTimeout(() => { + this.callRate = this.limitRate; + }, this.rollbackRateTime); + } +} diff --git a/packages/extension-base/src/strategy/api-request-strategy/index.ts b/packages/extension-base/src/strategy/api-request-strategy/index.ts new file mode 100644 index 00000000000..91f3a8a9e67 --- /dev/null +++ b/packages/extension-base/src/strategy/api-request-strategy/index.ts @@ -0,0 +1,108 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +import { SWError } from '@subwallet/extension-base/background/errors/SWError'; + +import { ApiRequest, ApiRequestContext, ApiRequestStrategy } from './types'; + +export abstract class BaseApiRequestStrategy implements ApiRequestStrategy { + private nextId = 0; + private isRunning = false; + private requestMap: Record> = {}; + private context: ApiRequestContext; + private processInterval: NodeJS.Timeout | undefined = undefined; + + private getId () { + return this.nextId++; + } + + protected constructor (context: ApiRequestContext) { + this.context = context; + } + + addRequest (run: ApiRequest['run'], ordinal: number) { + const newId = this.getId(); + + return new Promise((resolve, reject) => { + this.requestMap[newId] = { + id: newId, + status: 'pending', + retry: -1, + ordinal, + run, + resolve, + reject + }; + + if (!this.isRunning) { + this.process(); + } + }); + } + + abstract isRateLimited (error: Error): boolean; + + private process () { + this.stop(); + + this.isRunning = true; + const maxRetry = this.context.maxRetry; + + const interval = setInterval(() => { + const remainingRequests = Object.values(this.requestMap); + + if (remainingRequests.length === 0) { + this.isRunning = false; + clearInterval(interval); + + return; + } + + // Get first this.limit requests base on id + const requests = remainingRequests + .filter((request) => request.status !== 'running') + .sort((a, b) => a.id - b.id) + .sort((a, b) => a.ordinal - b.ordinal) + .slice(0, this.context.callRate); + + // Start requests + requests.forEach((request) => { + request.status = 'running'; + request.run().then((rs) => { + request.resolve(rs); + }).catch((e: Error) => { + const isRateLimited = this.isRateLimited(e); + + // Limit rate + if (isRateLimited) { + if (request.retry < maxRetry) { + request.status = 'pending'; + request.retry++; + this.context.reduceLimitRate(); + } else { + // Reject request + request.reject(new SWError('MAX_RETRY', String(e))); + } + } else { + request.reject(new SWError('UNKNOWN', String(e))); + } + }); + }); + }, this.context.intervalCheck); + + this.processInterval = interval; + } + + stop () { + clearInterval(this.processInterval); + this.processInterval = undefined; + } + + setContext (context: ApiRequestContext): void { + this.stop(); + + this.context = context; + + this.process(); + } +} diff --git a/packages/extension-base/src/strategy/api-request-strategy/types.ts b/packages/extension-base/src/strategy/api-request-strategy/types.ts new file mode 100644 index 00000000000..e53f59fbadb --- /dev/null +++ b/packages/extension-base/src/strategy/api-request-strategy/types.ts @@ -0,0 +1,27 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +export interface ApiRequestContext { + callRate: number; // limit per interval check + limitRate: number; // max rate per interval check + intervalCheck: number; // interval check in ms + maxRetry: number; // interval check in ms + reduceLimitRate: () => void; +} + +export interface ApiRequestStrategy { + addRequest: (run: ApiRequest['run'], ordinal: number) => Promise; + setContext: (context: ApiRequestContext) => void; + stop: () => void; +} + +export interface ApiRequest { + id: number; + retry: number; // retry < 1 not start, retry === 0 start, retry > 0 number of retry + /** Serve smaller first */ + ordinal: number; + status: 'pending' | 'running'; + run: () => Promise; + resolve: (value: any) => T; + reject: (error?: any) => void; +} diff --git a/packages/extension-base/src/strategy/api-request-strategy/utils/index.ts b/packages/extension-base/src/strategy/api-request-strategy/utils/index.ts new file mode 100644 index 00000000000..6d43ba9ec5a --- /dev/null +++ b/packages/extension-base/src/strategy/api-request-strategy/utils/index.ts @@ -0,0 +1,31 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +import fetch from 'cross-fetch'; + +export const postRequest = (url: string, body: any, headers?: Record, jsonBody = true) => { + return fetch(url, { + method: 'POST', + headers: headers || { + 'Content-Type': 'application/json' + }, + body: jsonBody ? JSON.stringify(body) : (body as string) + }); +}; + +export const getRequest = (url: string, params?: Record, headers?: Record) => { + const queryString = params + ? Object.keys(params) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`) + .join('&') + : ''; + + const _url = `${url}?${queryString}`; + + return fetch(_url, { + method: 'GET', + headers: headers || { + 'Content-Type': 'application/json' + } + }); +}; diff --git a/packages/extension-base/src/types/account/info/keyring.ts b/packages/extension-base/src/types/account/info/keyring.ts index a092645719d..53e9efb7a42 100644 --- a/packages/extension-base/src/types/account/info/keyring.ts +++ b/packages/extension-base/src/types/account/info/keyring.ts @@ -135,7 +135,7 @@ export const ACCOUNT_CHAIN_TYPE_ORDINAL_MAP: Record = { [AccountChainType.BITCOIN]: 5 }; -export const SUPPORTED_ACCOUNT_CHAIN_TYPES = ['substrate', 'ethereum', 'ton', 'cardano']; +export const SUPPORTED_ACCOUNT_CHAIN_TYPES: AccountChainType[] = [AccountChainType.SUBSTRATE, AccountChainType.ETHEREUM, AccountChainType.TON, AccountChainType.CARDANO, AccountChainType.BITCOIN]; export enum AccountActions { DERIVE = 'DERIVE', diff --git a/packages/extension-base/src/types/balance/index.ts b/packages/extension-base/src/types/balance/index.ts index 586591362b3..d5c204584f7 100644 --- a/packages/extension-base/src/types/balance/index.ts +++ b/packages/extension-base/src/types/balance/index.ts @@ -3,7 +3,7 @@ import { _ChainAsset, _ChainInfo } from '@subwallet/chain-list/types'; import { _BalanceMetadata, APIItemState, ExtrinsicType } from '@subwallet/extension-base/background/KoniTypes'; -import { _CardanoApi, _EvmApi, _SubstrateApi, _TonApi } from '@subwallet/extension-base/services/chain-service/types'; +import { _BitcoinApi, _CardanoApi, _EvmApi, _SubstrateApi, _TonApi } from '@subwallet/extension-base/services/chain-service/types'; import { BN } from '@polkadot/util'; @@ -37,7 +37,6 @@ export interface BalanceItem { // substrate fields metadata?: _BalanceMetadata; } - /** Balance info of all tokens on an address */ export type BalanceInfo = Record; // Key is tokenSlug /** Balance info of all addresses */ @@ -72,3 +71,7 @@ export interface SubscribeTonPalletBalance extends SubscribeBasePalletBalance { export interface SusbcribeCardanoPalletBalance extends SubscribeBasePalletBalance { cardanoApi: _CardanoApi; } + +export interface SusbcribeBitcoinPalletBalance extends SubscribeBasePalletBalance { + bitcoinApi: _BitcoinApi; +} diff --git a/packages/extension-base/src/types/balance/transfer.ts b/packages/extension-base/src/types/balance/transfer.ts index 9db102c5874..a96f5820a8d 100644 --- a/packages/extension-base/src/types/balance/transfer.ts +++ b/packages/extension-base/src/types/balance/transfer.ts @@ -1,12 +1,15 @@ // Copyright 2019-2022 @subwallet/extension-base authors & contributors // SPDX-License-Identifier: Apache-2.0 +import { PsbtTransactionArg } from '@subwallet/extension-base/background/KoniTypes'; import { BaseRequestSign } from '@subwallet/extension-base/types'; +import { Psbt } from 'bitcoinjs-lib'; import { FeeChainType, FeeDetail, TransactionFee } from '../fee'; export interface RequestSubscribeTransfer extends TransactionFee { address: string; + to?: string; chain: string; value: string; token: string; @@ -22,6 +25,10 @@ export interface ResponseSubscribeTransfer { error?: string; } +export interface RequestSubmitTransferWithId extends RequestSubmitTransfer{ + id?: string; +} + export interface RequestSubmitTransfer extends BaseRequestSign, TransactionFee { chain: string; from: string; @@ -31,3 +38,15 @@ export interface RequestSubmitTransfer extends BaseRequestSign, TransactionFee { value: string; transferBounceable?: boolean; } + +export interface RequestSubmitSignPsbtTransfer extends BaseRequestSign { + id: string; + chain: string; + from: string; + to: string; + value: string; + txInput: PsbtTransactionArg[]; + txOutput: PsbtTransactionArg[]; + tokenSlug: string; + psbt: Psbt; +} diff --git a/packages/extension-base/src/types/bitcoin.ts b/packages/extension-base/src/types/bitcoin.ts new file mode 100644 index 00000000000..8873948b2b3 --- /dev/null +++ b/packages/extension-base/src/types/bitcoin.ts @@ -0,0 +1,112 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +// https://github.com/leather-wallet/extension/blob/dev/src/app/query/bitcoin/bitcoin-client.ts +export interface UtxoResponseItem { + txid: string; + vout: number; + status: { + confirmed: boolean; + block_height?: number; + block_hash?: string; + block_time?: number; + }; + value: number; +} + +// https://github.com/leather-wallet/extension/blob/dev/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts +export interface DetermineUtxosForSpendArgs { + sender: string; + amount: number; + feeRate: number; + recipient: string; + utxos: UtxoResponseItem[]; +} + +interface DetermineUtxosOutput { + value: number; + address?: string; +} + +export interface DetermineUtxosForSpendResult { + filteredUtxos: UtxoResponseItem[]; + inputs: UtxoResponseItem[]; + outputs: DetermineUtxosOutput[], + size: number; + fee: number; +} + +// https://github.com/leather-wallet/extension/blob/dev/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts +export class InsufficientFundsError extends Error { + constructor () { + super('Insufficient funds'); + } +} +// Source: https://github.com/Blockstream/esplora/blob/master/API.md#transaction-format +// --------------- +interface BitcoinTransactionIssuance { + asset_id: string; + is_reissuance: boolean; + asset_blinding_nonce: number; + asset_entropy: number; + contract_hash: string; + assetamount?: number; + assetamountcommitment?: number; + tokenamount?: number; + tokenamountcommitment?: number; +} + +interface BitcoinTransactionPegOut { + genesis_hash: string; + scriptpubkey: string; + scriptpubkey_asm: string; + scriptpubkey_address: string; +} + +export interface BitcoinTransactionStatus { + confirmed: boolean; + block_height?: number | null; + block_hash?: string | null; + block_time?: number | null; +} + +export interface BitcoinTransactionVectorOutput { + scriptpubkey: string; + scriptpubkey_asm: string; + scriptpubkey_type: string; + scriptpubkey_address: string; + value: number; + valuecommitment?: number; + asset?: string; + assetcommitment?: number; + pegout?: BitcoinTransactionPegOut | null; +} + +export interface BitcoinTransactionVectorInput { + inner_redeemscript_asm?: string; + inner_witnessscript_asm?: string; + is_coinbase: boolean; + is_pegin?: boolean; + issuance?: BitcoinTransactionIssuance | null; + prevout: BitcoinTransactionVectorOutput; + scriptsig: string; + scriptsig_asm: string; + sequence: number; + txid: string; + vout: number; + witness: string[]; +} + +export interface BitcoinTx { + fee: number; + locktime: number; + size: number; + status: BitcoinTransactionStatus; + tx_type?: string; + txid: string; + version: number; + vin: BitcoinTransactionVectorInput[]; + vout: BitcoinTransactionVectorOutput[]; + weight: number; +} +// --------------- diff --git a/packages/extension-base/src/types/fee/base.ts b/packages/extension-base/src/types/fee/base.ts index 2d7fb1245b2..572fd02ef91 100644 --- a/packages/extension-base/src/types/fee/base.ts +++ b/packages/extension-base/src/types/fee/base.ts @@ -1,7 +1,7 @@ // Copyright 2019-2022 @subwallet/extension-base // SPDX-License-Identifier: Apache-2.0 -export type FeeChainType = 'evm' | 'substrate' | 'ton' | 'cardano'; +export type FeeChainType = 'evm' | 'substrate' | 'ton' | 'cardano' | 'bitcoin'; export interface BaseFeeInfo { busyNetwork: boolean; @@ -11,3 +11,7 @@ export interface BaseFeeInfo { export interface BaseFeeDetail { estimatedFee: string; } + +export interface BaseFeeTime { + time: number; // in milliseconds +} diff --git a/packages/extension-base/src/types/fee/bitcoin.ts b/packages/extension-base/src/types/fee/bitcoin.ts new file mode 100644 index 00000000000..130aabca4b6 --- /dev/null +++ b/packages/extension-base/src/types/fee/bitcoin.ts @@ -0,0 +1,25 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +import { BaseFeeDetail, BaseFeeInfo, BaseFeeTime } from './base'; +import { FeeDefaultOption } from './option'; + +export interface BitcoinFeeRate { + feeRate: number; +} + +export type BitcoinFeeRateDetail = BitcoinFeeRate & BaseFeeTime; + +export interface BitcoinFeeInfo extends BaseFeeInfo { + type: 'bitcoin'; + options: { + slow: BitcoinFeeRateDetail; + average: BitcoinFeeRateDetail; + fast: BitcoinFeeRateDetail; + default: FeeDefaultOption; + } +} + +export interface BitcoinFeeDetail extends BitcoinFeeInfo, BaseFeeDetail { + vSize: number; +} diff --git a/packages/extension-base/src/types/fee/index.ts b/packages/extension-base/src/types/fee/index.ts index 0de282e4daf..440a26ad358 100644 --- a/packages/extension-base/src/types/fee/index.ts +++ b/packages/extension-base/src/types/fee/index.ts @@ -6,3 +6,4 @@ export * from './evm'; export * from './option'; export * from './subscription'; export * from './substrate'; +export * from './bitcoin'; diff --git a/packages/extension-base/src/types/fee/subscription.ts b/packages/extension-base/src/types/fee/subscription.ts index 9c93844c5a1..178dba84e2c 100644 --- a/packages/extension-base/src/types/fee/subscription.ts +++ b/packages/extension-base/src/types/fee/subscription.ts @@ -1,6 +1,7 @@ // Copyright 2019-2022 @subwallet/extension-base // SPDX-License-Identifier: Apache-2.0 +import { BitcoinFeeDetail, BitcoinFeeInfo, BitcoinFeeRate } from '@subwallet/extension-base/types'; import { BehaviorSubject } from 'rxjs'; import { CardanoFeeDetail, CardanoFeeInfo, CardanoTipInfo } from './cardano'; @@ -8,9 +9,9 @@ import { EvmEIP1559FeeOption, EvmFeeDetail, EvmFeeInfo } from './evm'; import { SubstrateFeeDetail, SubstrateFeeInfo, SubstrateTipInfo } from './substrate'; import { TonFeeDetail, TonFeeInfo, TonTipInfo } from './ton'; -export type FeeInfo = EvmFeeInfo | SubstrateFeeInfo | TonFeeInfo | CardanoFeeInfo; -export type FeeDetail = EvmFeeDetail | SubstrateFeeDetail | TonFeeDetail | CardanoFeeDetail; -export type FeeCustom = EvmEIP1559FeeOption | SubstrateTipInfo | TonTipInfo | CardanoTipInfo; +export type FeeInfo = EvmFeeInfo | SubstrateFeeInfo | TonFeeInfo | CardanoFeeInfo | BitcoinFeeInfo; +export type FeeDetail = EvmFeeDetail | SubstrateFeeDetail | TonFeeDetail | CardanoFeeDetail | BitcoinFeeDetail; +export type FeeCustom = EvmEIP1559FeeOption | SubstrateTipInfo | TonTipInfo | CardanoTipInfo | BitcoinFeeRate; export interface FeeSubscription { observer: BehaviorSubject; diff --git a/packages/extension-base/src/types/index.ts b/packages/extension-base/src/types/index.ts index 1a6cfb936f0..8e01086aa5c 100644 --- a/packages/extension-base/src/types/index.ts +++ b/packages/extension-base/src/types/index.ts @@ -27,3 +27,4 @@ export * from './swap'; export * from './transaction'; export * from './yield'; export * from './setting'; +export * from './bitcoin'; diff --git a/packages/extension-base/src/utils/account/analyze.ts b/packages/extension-base/src/utils/account/analyze.ts index a9f18ea6937..3d456318e10 100644 --- a/packages/extension-base/src/utils/account/analyze.ts +++ b/packages/extension-base/src/utils/account/analyze.ts @@ -4,8 +4,9 @@ import { _ChainInfo } from '@subwallet/chain-list/types'; import { resolveAzeroAddressToDomain, resolveAzeroDomainToAddress } from '@subwallet/extension-base/koni/api/dotsama/domain'; import { _SubstrateApi } from '@subwallet/extension-base/services/chain-service/types'; -import { _chainInfoToChainType, _getChainSubstrateAddressPrefix } from '@subwallet/extension-base/services/chain-service/utils'; +import { _getChainSubstrateAddressPrefix, _isChainInfoCompatibleWithAccountInfo } from '@subwallet/extension-base/services/chain-service/utils'; import { AbstractAddressJson, AccountChainType, AccountProxy, AddressJson, AnalyzeAddress, AnalyzedGroup, ResponseInputAccountSubscribe } from '@subwallet/extension-base/types'; +import { getKeypairTypeByAddress } from '@subwallet/keyring'; import { isAddress } from '@polkadot/util-crypto'; @@ -67,14 +68,13 @@ export const _analyzeAddress = async (data: string, accountProxies: AccountProxy const chain = chainInfo.slug; const _data = data.trim().toLowerCase(); const options: AnalyzeAddress[] = []; - const currentChainType = _chainInfoToChainType(chainInfo); let current: AnalyzeAddress | undefined; // Filter account proxies for (const accountProxy of accountProxies) { const _name = accountProxy.name.trim().toLowerCase(); const nameCondition = isNameValid(_data, _name); - const filterAccounts = accountProxy.accounts.filter((account) => account.chainType === currentChainType); + const filterAccounts = accountProxy.accounts.filter((account) => _isChainInfoCompatibleWithAccountInfo(chainInfo, account.chainType, account.type)); for (const account of filterAccounts) { const addressCondition = isStrValidWithAddress(_data, account, chainInfo); @@ -108,7 +108,7 @@ export const _analyzeAddress = async (data: string, accountProxies: AccountProxy } } - const filterContacts = contacts.filter((contact) => contact.chainType === currentChainType); + const filterContacts = contacts.filter((contact) => _isChainInfoCompatibleWithAccountInfo(chainInfo, contact.chainType, getKeypairTypeByAddress(contact.address))); // Filter address book addresses for (const contact of filterContacts) { diff --git a/packages/extension-base/src/utils/account/common.ts b/packages/extension-base/src/utils/account/common.ts index 9e1d66f8ef4..ff8b26af884 100644 --- a/packages/extension-base/src/utils/account/common.ts +++ b/packages/extension-base/src/utils/account/common.ts @@ -9,6 +9,7 @@ import { AccountChainType } from '@subwallet/extension-base/types'; import { getAccountChainTypeFromKeypairType } from '@subwallet/extension-base/utils'; import { decodeAddress, encodeAddress, getKeypairTypeByAddress, isAddress, isBitcoinAddress, isCardanoAddress, isTonAddress } from '@subwallet/keyring'; import { KeypairType } from '@subwallet/keyring/types'; +import { getBitcoinAddressInfo } from '@subwallet/keyring/utils/address/validate'; import { ethereumEncode, isEthereumAddress } from '@polkadot/util-crypto'; @@ -70,29 +71,31 @@ export const getAccountChainTypeForAddress = (address: string): AccountChainType return getAccountChainTypeFromKeypairType(type); }; -interface AddressesByChainType { - [ChainType.SUBSTRATE]: string[], - [ChainType.EVM]: string[], - [ChainType.BITCOIN]: string[], - [ChainType.TON]: string[], - [ChainType.CARDANO]: string[] +type AddressesByChainType = { + [key in ChainType]: string[] } -export function getAddressesByChainType (addresses: string[], chainTypes: ChainType[]): string[] { - const addressByChainTypeMap = getAddressesByChainTypeMap(addresses); +interface ExtendAddressesByChainType extends AddressesByChainType { + _bitcoin: string[]; +} + +// TODO: Recheck the usage of this function for Bitcoin; it is currently applied to history. +export function getAddressesByChainType (addresses: string[], chainTypes: ChainType[], chainInfo?: _ChainInfo): string[] { + const addressByChainTypeMap = getAddressesByChainTypeMap(addresses, chainInfo); return chainTypes.map((chainType) => { return addressByChainTypeMap[chainType]; }).flat(); // todo: recheck } -export function getAddressesByChainTypeMap (addresses: string[]): AddressesByChainType { - const addressByChainType: AddressesByChainType = { +export function getAddressesByChainTypeMap (addresses: string[], chainInfo?: _ChainInfo): ExtendAddressesByChainType { + const addressByChainType: ExtendAddressesByChainType = { substrate: [], evm: [], bitcoin: [], ton: [], - cardano: [] + cardano: [], + _bitcoin: [] }; addresses.forEach((address) => { @@ -101,7 +104,17 @@ export function getAddressesByChainTypeMap (addresses: string[]): AddressesByCha } else if (isTonAddress(address)) { addressByChainType.ton.push(address); } else if (isBitcoinAddress(address)) { - addressByChainType.bitcoin.push(address); + const addressInfo = getBitcoinAddressInfo(address); + + if (chainInfo?.bitcoinInfo) { + const isNetworkMatch = addressInfo.network === chainInfo.bitcoinInfo.bitcoinNetwork; + + if (isNetworkMatch) { + addressByChainType.bitcoin.push(address); + } else { + addressByChainType._bitcoin.push(address); + } + } } else if (isCardanoAddress(address)) { addressByChainType.cardano.push(address); } else { diff --git a/packages/extension-base/src/utils/account/derive/info/solo.ts b/packages/extension-base/src/utils/account/derive/info/solo.ts index 6ec8a1d258a..3d9ab5c2294 100644 --- a/packages/extension-base/src/utils/account/derive/info/solo.ts +++ b/packages/extension-base/src/utils/account/derive/info/solo.ts @@ -1,40 +1,74 @@ // Copyright 2019-2022 @subwallet/extension-base authors & contributors // SPDX-License-Identifier: Apache-2.0 -import { AccountDeriveData, DeriveInfo, NextDerivePair } from '@subwallet/extension-base/types'; +import { AccountDeriveData, DeriveInfo, IDerivePathInfo_, NextDerivePair } from '@subwallet/extension-base/types'; import { getDerivePath } from '@subwallet/keyring'; -import { EthereumKeypairTypes, KeypairType, KeyringPair, SubstrateKeypairType, SubstrateKeypairTypes, TonWalletContractVersion } from '@subwallet/keyring/types'; +import { BitcoinKeypairTypes, EthereumKeypairTypes, KeypairType, KeyringPair, SubstrateKeypairType, SubstrateKeypairTypes, TonWalletContractVersion } from '@subwallet/keyring/types'; import { keyring } from '@subwallet/ui-keyring'; import { t } from 'i18next'; import { assert } from '@polkadot/util'; -import { validateCardanoDerivationPath, validateEvmDerivationPath, validateOtherSubstrateDerivationPath, validateSr25519DerivationPath, validateTonDerivationPath, validateUnifiedDerivationPath } from '../validate'; +import { validateBitcoinDerivationPath, validateCardanoDerivationPath, validateEvmDerivationPath, validateOtherSubstrateDerivationPath, validateSr25519DerivationPath, validateTonDerivationPath, validateUnifiedDerivationPath } from '../validate'; + +const bitPathLv1 = "m/{proposal}'/{slip44}'/{firstIndex}'/0/0"; +const bitPathLv2 = "m/{proposal}'/{slip44}'/{firstIndex}'/0/0/{secondIndex}"; + +const getBitLv1DerivePathFunction = (slip44: number, proposal: number) => { + return bitPathLv1 + .replace('{proposal}', proposal.toString()) + .replace('{slip44}', slip44.toString()); +}; + +const getBitLv2DerivePathFunction = (slip44: number, proposal: number) => { + return bitPathLv2 + .replace('{proposal}', proposal.toString()) + .replace('{slip44}', slip44.toString()); +}; + +const level1DerivationPathMap: Partial> = { + ethereum: "m/44'/60'/0'/0/{firstIndex}", + ton: "m/44'/607'/{firstIndex}'", + cardano: "m/1852'/1815'/{firstIndex}'", + 'bitcoin-44': getBitLv1DerivePathFunction(0, 44), + 'bitcoin-84': getBitLv1DerivePathFunction(0, 84), + 'bitcoin-86': getBitLv1DerivePathFunction(0, 86), + 'bittest-44': getBitLv1DerivePathFunction(1, 44), + 'bittest-84': getBitLv1DerivePathFunction(1, 84), + 'bittest-86': getBitLv1DerivePathFunction(1, 86) +}; + +const level2DerivationPathMap: Partial> = { + ethereum: "m/44'/60'/0'/0/{firstIndex}/{secondIndex}", + ton: "m/44'/607'/{firstIndex}'/{secondIndex}'", + cardano: "m/1852'/1815'/{firstIndex}'/{secondIndex}'", + 'bitcoin-44': getBitLv2DerivePathFunction(0, 44), + 'bitcoin-84': getBitLv2DerivePathFunction(0, 84), + 'bitcoin-86': getBitLv2DerivePathFunction(0, 86), + 'bittest-44': getBitLv2DerivePathFunction(1, 44), + 'bittest-84': getBitLv2DerivePathFunction(1, 84), + 'bittest-86': getBitLv2DerivePathFunction(1, 86) +}; export const parseUnifiedSuriToDerivationPath = (suri: string, type: KeypairType): string => { const reg = /^\/\/(\d+)(\/\/\d+)?$/; if (suri.match(reg)) { const [, firstIndex, secondData] = suri.match(reg) as string[]; - const first = parseInt(firstIndex, 10); if (secondData) { const [, secondIndex] = secondData.match(/\/\/(\d+)/) as string[]; - if (type === 'ethereum') { - return `m/44'/60'/0'/0/${first}/${secondIndex}`; - } else if (type === 'ton') { - return `m/44'/607'/${first}'/${secondIndex}'`; - } else if (type === 'cardano') { - return `m/1852'/1815'/${first}'/${secondIndex}'`; + const path = level2DerivationPathMap[type]; + + if (path) { + return path.replace('{firstIndex}', firstIndex).replace('{secondIndex}', secondIndex); } } else { - if (type === 'ethereum') { - return `m/44'/60'/0'/0/${first}`; - } else if (type === 'ton') { - return `m/44'/607'/${first}'`; - } else if (type === 'cardano') { - return `m/1852'/1815'/${first}'`; + const path = level1DerivationPathMap[type]; + + if (path) { + return path.replace('{firstIndex}', firstIndex); } } @@ -46,19 +80,42 @@ export const parseUnifiedSuriToDerivationPath = (suri: string, type: KeypairType return ''; }; +const validateNonSubstrateDerivationPath = (derivePath: string, type: KeypairType): IDerivePathInfo_ | undefined => { + let validateTypeRs: IDerivePathInfo_ | undefined; + + switch (type) { + case 'ethereum': + validateTypeRs = validateEvmDerivationPath(derivePath); + break; + case 'ton': + validateTypeRs = validateTonDerivationPath(derivePath); + break; + case 'cardano': + validateTypeRs = validateCardanoDerivationPath(derivePath); + break; + case 'bitcoin-44': + case 'bitcoin-84': + case 'bitcoin-86': + case 'bittest-44': + case 'bittest-84': + case 'bittest-86': + validateTypeRs = validateBitcoinDerivationPath(derivePath); + break; + } + + if (validateTypeRs && validateTypeRs.type === type) { + return validateTypeRs; + } else { + return undefined; + } +}; + export const getSoloDerivationInfo = (type: KeypairType, metadata: AccountDeriveData = {}): DeriveInfo => { const { derivationPath: derivePath, parentAddress, suri } = metadata; if (suri) { if (derivePath) { - const validateTypeFunc = type === 'ethereum' - ? validateEvmDerivationPath - : type === 'ton' - ? validateTonDerivationPath - : type === 'cardano' - ? validateCardanoDerivationPath - : () => undefined; - const validateTypeRs = validateTypeFunc(derivePath); + const validateTypeRs = validateNonSubstrateDerivationPath(derivePath, type); if (validateTypeRs) { return { @@ -116,14 +173,7 @@ export const getSoloDerivationInfo = (type: KeypairType, metadata: AccountDerive } } else { if (derivePath) { - const validateTypeFunc = type === 'ethereum' - ? validateEvmDerivationPath - : type === 'ton' - ? validateTonDerivationPath - : type === 'cardano' - ? validateCardanoDerivationPath - : () => undefined; - const validateTypeRs = validateTypeFunc(derivePath); + const validateTypeRs = validateNonSubstrateDerivationPath(derivePath, type); if (validateTypeRs) { return { @@ -251,6 +301,7 @@ export const derivePair = (parentPair: KeyringPair, name: string, suri: string, const isEvm = EthereumKeypairTypes.includes(parentPair.type); const isTon = parentPair.type === 'ton'; const isCardano = parentPair.type === 'cardano'; + const isBitcoin = BitcoinKeypairTypes.includes(parentPair.type); const meta = { name, @@ -264,8 +315,14 @@ export const derivePair = (parentPair: KeyringPair, name: string, suri: string, meta.tonContractVersion = parentPair.ton.contractVersion; } - if (derivationPath && (isEvm || isTon || isCardano)) { - return isEvm ? parentPair.evm.deriveCustom(derivationPath, meta) : isTon ? parentPair.ton.deriveCustom(derivationPath, meta) : parentPair.cardano.deriveCustom(derivationPath, meta); + if (derivationPath && (isEvm || isTon || isCardano || isBitcoin)) { + return isEvm + ? parentPair.evm.deriveCustom(derivationPath, meta) + : isTon + ? parentPair.ton.deriveCustom(derivationPath, meta) + : isCardano + ? parentPair.cardano.deriveCustom(derivationPath, meta) + : parentPair.bitcoin.deriveCustom(derivationPath, meta); } else { return parentPair.substrate.derive(suri, meta); } diff --git a/packages/extension-base/src/utils/account/derive/info/unified.ts b/packages/extension-base/src/utils/account/derive/info/unified.ts index 62af0154380..fb2556090fa 100644 --- a/packages/extension-base/src/utils/account/derive/info/unified.ts +++ b/packages/extension-base/src/utils/account/derive/info/unified.ts @@ -89,6 +89,8 @@ export const findUnifiedNextDerive = (proxyId: string, accounts: AccountProxyMap index++; } else if (currentDepth === 0 && deriveIndex === 0 && index > deriveIndex) { // Special case for the first account on the root + } else if (deriveIndex === index - 1) { + // Special case, increased index before that, ex: 1/0, 1/1, 1/2 } else { break; } diff --git a/packages/extension-base/src/utils/account/derive/validate.ts b/packages/extension-base/src/utils/account/derive/validate.ts index 04c36d9d666..9615b88830b 100644 --- a/packages/extension-base/src/utils/account/derive/validate.ts +++ b/packages/extension-base/src/utils/account/derive/validate.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { DerivePathInfo, IDerivePathInfo_ } from '@subwallet/extension-base/types'; -import { KeypairType, SubstrateKeypairType } from '@subwallet/keyring/types'; +import { BitcoinKeypairTypes, KeypairType, SubstrateKeypairType } from '@subwallet/keyring/types'; export const validateUnifiedDerivationPath = (raw: string): DerivePathInfo | undefined => { const reg = /^\/\/(\d+)(\/\/\d+)?$/; @@ -125,6 +125,76 @@ export const validateTonDerivationPath = (raw: string): IDerivePathInfo_ | undef } }; +export const validateBitcoinDerivationPath = (raw: string): IDerivePathInfo_ | undefined => { + const reg = /^m\/(44|84|86)'\/([01])'\/(\d+)'\/0\/0(\/\d+)?$/; + + if (raw.match(reg)) { + const [, proposal, slip44, firstIndex, secondData] = raw.match(reg) as string[]; + const first = parseInt(firstIndex, 10); + const autoIndexes: number[] = [first]; + + let depth: number; + let suri = `//${first}`; + let type: KeypairType | undefined; + + if (slip44 === '0') { + switch (proposal) { + case '44': + type = 'bitcoin-44'; + break; + case '84': + type = 'bitcoin-84'; + break; + case '86': + type = 'bitcoin-86'; + break; + } + } else if (slip44 === '1') { + switch (proposal) { + case '44': + type = 'bittest-44'; + break; + case '84': + type = 'bittest-84'; + break; + case '86': + type = 'bittest-86'; + break; + } + } + + if (!type) { + return undefined; + } + + if (first === 0) { + depth = 0; + } else { + depth = 1; + } + + if (secondData) { + const [, secondIndex] = secondData.match(/\/(\d+)/) as string[]; + + const second = parseInt(secondIndex, 10); + + autoIndexes.push(second); + depth = 2; + suri += `//${second}`; + } + + return { + depth, + type, + suri, + derivationPath: raw, + autoIndexes + }; + } else { + return undefined; + } +}; + export const validateCardanoDerivationPath = (raw: string): IDerivePathInfo_ | undefined => { const reg = /^m\/1852'\/1815'\/(\d+)'(\/\d+')?$/; @@ -239,10 +309,18 @@ export const validateDerivationPath = (raw: string, type?: KeypairType): DeriveP return validateOtherSubstrateDerivationPath(raw, type); } else if (type === 'cardano') { return validateCardanoDerivationPath(raw); + } else if (BitcoinKeypairTypes.includes(type)) { + const rs = validateBitcoinDerivationPath(raw); + + if (rs && rs.type === type) { + return rs; + } + + return undefined; } else { return undefined; } } else { - return validateUnifiedDerivationPath(raw) || validateEvmDerivationPath(raw) || validateTonDerivationPath(raw) || validateSr25519DerivationPath(raw) || validateCardanoDerivationPath(raw); + return validateUnifiedDerivationPath(raw) || validateEvmDerivationPath(raw) || validateTonDerivationPath(raw) || validateSr25519DerivationPath(raw) || validateCardanoDerivationPath(raw) || validateBitcoinDerivationPath(raw); } }; diff --git a/packages/extension-base/src/utils/account/transform.ts b/packages/extension-base/src/utils/account/transform.ts index 6f1d6637bb3..4be6454e883 100644 --- a/packages/extension-base/src/utils/account/transform.ts +++ b/packages/extension-base/src/utils/account/transform.ts @@ -58,17 +58,17 @@ export const getAccountChainTypeFromKeypairType = (type: KeypairType): AccountCh : AccountChainType.SUBSTRATE; }; -export const getDefaultKeypairTypeFromAccountChainType = (type: AccountChainType): KeypairType => { +export const getDefaultKeypairTypeFromAccountChainType = (type: AccountChainType): KeypairType[] => { if (type === AccountChainType.ETHEREUM) { - return 'ethereum'; + return ['ethereum']; } else if (type === AccountChainType.TON) { - return 'ton'; + return ['ton']; } else if (type === AccountChainType.BITCOIN) { - return 'bitcoin-84'; + return ['bitcoin-44', 'bitcoin-84', 'bitcoin-86', 'bittest-44', 'bittest-84', 'bittest-86']; } else if (type === AccountChainType.CARDANO) { - return 'cardano'; + return ['cardano']; } else { - return 'sr25519'; + return ['sr25519']; } }; @@ -274,6 +274,10 @@ export const getAccountTransactionActions = (signMode: AccountSignMode, networkT return [ ...BASE_TRANSFER_ACTIONS ]; + case AccountChainType.BITCOIN: + return [ + ...BASE_TRANSFER_ACTIONS + ]; } } else if (signMode === AccountSignMode.QR) { switch (networkType) { @@ -310,6 +314,8 @@ export const getAccountTransactionActions = (signMode: AccountSignMode, networkT return []; case AccountChainType.CARDANO: return []; + case AccountChainType.BITCOIN: + return []; } } else if (signMode === AccountSignMode.GENERIC_LEDGER) { switch (networkType) { @@ -341,6 +347,8 @@ export const getAccountTransactionActions = (signMode: AccountSignMode, networkT ]; case AccountChainType.CARDANO: return []; + case AccountChainType.BITCOIN: + return []; } } else if (signMode === AccountSignMode.LEGACY_LEDGER) { // Only for Substrate const result: ExtrinsicType[] = []; diff --git a/packages/extension-base/src/utils/bitcoin/common.ts b/packages/extension-base/src/utils/bitcoin/common.ts new file mode 100644 index 00000000000..76bc96ec6ab --- /dev/null +++ b/packages/extension-base/src/utils/bitcoin/common.ts @@ -0,0 +1,111 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +import { _BitcoinApi } from '@subwallet/extension-base/services/chain-service/types'; +import { UtxoResponseItem } from '@subwallet/extension-base/types'; +import { filteredOutTxsUtxos, getInscriptionUtxos, getRuneUtxos } from '@subwallet/extension-base/utils'; +import { BitcoinAddressType } from '@subwallet/keyring/types'; +import { BtcSizeFeeEstimator, getBitcoinAddressInfo, validateBitcoinAddress } from '@subwallet/keyring/utils'; +import BigN from 'bignumber.js'; + +// Source: https://github.com/leather-wallet/extension/blob/dev/src/app/common/transactions/bitcoin/utils.ts +export function getSizeInfo (payload: { + inputLength: number; + recipients: string[]; + sender: string; +}) { + const { inputLength, recipients, sender } = payload; + const senderInfo = validateBitcoinAddress(sender) ? getBitcoinAddressInfo(sender) : null; + const inputAddressTypeWithFallback = senderInfo ? senderInfo.type : BitcoinAddressType.p2wpkh; + const outputMap: Record = {}; + + for (const recipient of recipients) { + const recipientInfo = validateBitcoinAddress(recipient) ? getBitcoinAddressInfo(recipient) : null; + const outputAddressTypeWithFallback = recipientInfo ? recipientInfo.type : BitcoinAddressType.p2wpkh; + const outputKey = outputAddressTypeWithFallback + '_output_count'; + + if (outputMap[outputKey]) { + outputMap[outputKey]++; + } else { + outputMap[outputKey] = 1; + } + } + + const txSizer = new BtcSizeFeeEstimator(); + + return txSizer.calcTxSize({ + input_script: inputAddressTypeWithFallback, + input_count: inputLength, + ...outputMap + }); +} + +// https://github.com/leather-wallet/extension/blob/dev/src/app/common/transactions/bitcoin/utils.ts +export function getSpendableAmount ({ feeRate, + recipients, + sender, + utxos }: { + utxos: UtxoResponseItem[]; + feeRate: number; + recipients: string[]; + sender: string; +}) { + const balance = utxos.map((utxo) => utxo.value).reduce((prevVal, curVal) => prevVal + curVal, 0); + + const size = getSizeInfo({ + inputLength: utxos.length, + recipients, + sender + }); + const fee = Math.ceil(size.txVBytes * feeRate); + const bigNumberBalance = new BigN(balance); + + return { + spendableAmount: BigN.max(0, bigNumberBalance.minus(fee)), + fee + }; +} + +export const getTransferableBitcoinUtxos = async (bitcoinApi: _BitcoinApi, address: string) => { + try { + const [utxos, runeTxsUtxos, inscriptionUtxos] = await Promise.all([ + bitcoinApi.api.getUtxos(address).catch((error) => { + console.log('Error fetching UTXOs:', error); + + return []; + }), + getRuneUtxos(bitcoinApi, address).catch((error) => { + console.log('Error fetching Rune UTXOs:', error); + + return []; + }), + getInscriptionUtxos(bitcoinApi, address).catch((error) => { + console.log('Error fetching Inscription UTXOs:', error); + + return []; + }) + ]); + + let filteredUtxos: UtxoResponseItem[]; + + if (!utxos || !utxos.length) { + return []; + } + + // filter out pending utxos + // filteredUtxos = filterOutPendingTxsUtxos(utxos); + + // filter out rune utxos + filteredUtxos = filteredOutTxsUtxos(utxos, runeTxsUtxos); + + // filter out dust utxos + // filter out inscription utxos + filteredUtxos = filteredOutTxsUtxos(utxos, inscriptionUtxos); + + return filteredUtxos; + } catch (error) { + console.log('Error while fetching Bitcoin balances', error); + + return []; + } +}; diff --git a/packages/extension-base/src/utils/bitcoin/fee.ts b/packages/extension-base/src/utils/bitcoin/fee.ts new file mode 100644 index 00000000000..f6af5c56ca6 --- /dev/null +++ b/packages/extension-base/src/utils/bitcoin/fee.ts @@ -0,0 +1,14 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +import { BitcoinFeeInfo, BitcoinFeeRate, FeeOption } from '@subwallet/extension-base/types'; + +export const combineBitcoinFee = (feeInfo: BitcoinFeeInfo, feeOptions?: FeeOption, feeCustom?: BitcoinFeeRate): BitcoinFeeRate => { + if (feeOptions && feeOptions !== 'custom') { + return feeInfo.options?.[feeOptions]; + } else if (feeOptions === 'custom' && feeCustom) { + return feeCustom; + } else { + return feeInfo.options?.[feeInfo.options.default]; + } +}; diff --git a/packages/extension-base/src/utils/bitcoin/index.ts b/packages/extension-base/src/utils/bitcoin/index.ts new file mode 100644 index 00000000000..974d4d14891 --- /dev/null +++ b/packages/extension-base/src/utils/bitcoin/index.ts @@ -0,0 +1,6 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +export * from './common'; +export * from './fee'; +export * from './utxo-management'; diff --git a/packages/extension-base/src/utils/bitcoin/utxo-management.ts b/packages/extension-base/src/utils/bitcoin/utxo-management.ts new file mode 100644 index 00000000000..f93563f5a6c --- /dev/null +++ b/packages/extension-base/src/utils/bitcoin/utxo-management.ts @@ -0,0 +1,291 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +import { TransactionError } from '@subwallet/extension-base/background/errors/TransactionError'; +import { BTC_DUST_AMOUNT } from '@subwallet/extension-base/constants'; +import { _BitcoinApi } from '@subwallet/extension-base/services/chain-service/types'; +import { DetermineUtxosForSpendArgs, InsufficientFundsError, TransferTxErrorType, UtxoResponseItem } from '@subwallet/extension-base/types'; +import { balanceFormatter, formatNumber } from '@subwallet/extension-base/utils'; +import { BitcoinAddressType } from '@subwallet/keyring/types'; +import { getBitcoinAddressInfo, validateBitcoinAddress } from '@subwallet/keyring/utils'; +import BigN from 'bignumber.js'; + +import { getSizeInfo, getSpendableAmount } from './common'; + +// https://github.com/leather-wallet/extension/blob/dev/src/app/common/transactions/bitcoin/utils.ts +// Check if the spendable amount drops when adding a utxo. If it drops, don't use that utxo. +// Method might be not particularly efficient as it would +// go through the utxo array multiple times, but it's reliable. +export function filterUneconomicalUtxos ({ feeRate, + recipients, + sender, + utxos }: { + utxos: UtxoResponseItem[]; + feeRate: number; + sender: string; + recipients: string[]; +}) { + const addressInfo = validateBitcoinAddress(sender) ? getBitcoinAddressInfo(sender) : null; + const inputAddressTypeWithFallback = addressInfo ? addressInfo.type : BitcoinAddressType.p2wpkh; + + const filteredAndSortUtxos = utxos + .filter((utxo) => utxo.value >= BTC_DUST_AMOUNT[inputAddressTypeWithFallback]) + .sort((a, b) => a.value - b.value); // ascending order + + return filteredAndSortUtxos.reduce((utxos, utxo, currentIndex) => { + const utxosWithout = utxos.filter((u) => u.txid !== utxo.txid); + + const { spendableAmount: spendableAmountWithout } = getSpendableAmount({ + utxos: utxosWithout, + feeRate, + recipients, + sender + }); + + const { spendableAmount } = getSpendableAmount({ + utxos, + feeRate, + recipients, + sender + }); + + // console.log(utxosWithout, feeWithout, spendableAmountWithout.toString()); + // console.log(utxos, fee, spendableAmount.toString()); + + if (spendableAmount.lte(0)) { + return utxosWithout; + } else { + // if spendable amount becomes bigger, do not use that utxo + return spendableAmountWithout.gt(spendableAmount) ? utxosWithout : utxos; + } + }, [...filteredAndSortUtxos]).reverse(); +} + +// https://github.com/leather-wallet/extension/blob/dev/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts +export function determineUtxosForSpendAll ({ feeRate, + recipient, + sender, + utxos }: DetermineUtxosForSpendArgs) { + if (!validateBitcoinAddress(recipient)) { + throw new Error('Cannot calculate spend of invalid address type'); + } + + const recipientAddressInfo = getBitcoinAddressInfo(recipient); + const recipientDustLimit = BTC_DUST_AMOUNT[recipientAddressInfo.type] || 546; + + const recipients = [recipient]; + + const filteredUtxos = filterUneconomicalUtxos({ utxos, feeRate, recipients, sender }); + + const sizeInfo = getSizeInfo({ + sender, + inputLength: filteredUtxos.length, + recipients + }); + + const amount = filteredUtxos.reduce((acc, utxo) => acc + utxo.value, 0) - Math.ceil(sizeInfo.txVBytes * feeRate); + + if (amount <= 0) { + throw new InsufficientFundsError(); + } + + if (amount < recipientDustLimit) { + const atLeastStr = formatNumber(recipientDustLimit, 8, balanceFormatter, { maxNumberFormat: 8, minNumberFormat: 8 }); + + throw new TransactionError( + TransferTxErrorType.NOT_ENOUGH_VALUE, + `You must transfer at least ${atLeastStr} BTC` + ); + } + + // Fee has already been deducted from the amount with send all + const outputs = [{ value: amount, address: recipient }]; + + const fee = Math.ceil(sizeInfo.txVBytes * feeRate); + + return { + inputs: filteredUtxos, + outputs, + size: sizeInfo.txVBytes, + fee, + isCustomFeeRate: false + }; +} + +// https://github.com/leather-wallet/extension/blob/dev/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts +export function determineUtxosForSpend ({ amount, + feeRate, + recipient, + sender, + utxos }: DetermineUtxosForSpendArgs) { + if (!validateBitcoinAddress(recipient)) { + throw new Error('Cannot calculate spend of invalid address type'); + } + + const recipientAddressInfo = getBitcoinAddressInfo(recipient); + const recipientDustLimit = BTC_DUST_AMOUNT[recipientAddressInfo.type] || 546; + + if (amount < recipientDustLimit) { + const atLeastStr = formatNumber(recipientDustLimit, 8, balanceFormatter, { maxNumberFormat: 8, minNumberFormat: 8 }); + + throw new TransactionError(TransferTxErrorType.NOT_ENOUGH_VALUE, `You must transfer at least ${atLeastStr} BTC`); + } + + const orderedUtxos = utxos.sort((a, b) => b.value - a.value); + const recipients = [recipient, sender]; + const filteredUtxos = filterUneconomicalUtxos({ + utxos: orderedUtxos, + feeRate, + recipients, + sender + }); + + const neededUtxos = []; + let sum = new BigN(0); + let sizeInfo = null; + + for (const utxo of filteredUtxos) { + sizeInfo = getSizeInfo({ + inputLength: neededUtxos.length, + sender, + recipients + }); + + const currentValue = new BigN(amount).plus(Math.ceil(sizeInfo.txVBytes * feeRate)); + + if (sum.gte(currentValue)) { + break; + } + + sum = sum.plus(utxo.value); + neededUtxos.push(utxo); + + // re calculate size info, some case array end + sizeInfo = getSizeInfo({ + inputLength: neededUtxos.length, + sender, + recipients + }); + } + + if (!sizeInfo) { + throw new InsufficientFundsError(); + } + + const fee = Math.ceil(sizeInfo.txVBytes * feeRate); + + const amountLeft = sum.minus(amount).minus(fee); + + if (amountLeft.lte(0)) { + throw new InsufficientFundsError(); + } + + const senderAddressInfo = getBitcoinAddressInfo(sender); + const dustLimit = BTC_DUST_AMOUNT[senderAddressInfo.type] || 546; + + const outputs = [ + // outputs[0] = the desired amount going to recipient + { value: amount, address: recipient } + ]; + + if (amountLeft.gte(dustLimit)) { + // outputs[1] = the remainder to be returned to a change address + outputs.push({ value: amountLeft.toNumber(), address: sender }); + } else { + // Todo: This solution for improve later, current throw error + // // Increase the fee to use the remaining balance + console.warn(`Change output of ${amountLeft.toString()} satoshis is below dust limit (${dustLimit} satoshis for ${senderAddressInfo.type}). Omitting change output.`); + // + sizeInfo = getSizeInfo({ + inputLength: neededUtxos.length, + sender, + recipients: recipients.slice(0, 1) + }); + const newFee = sum.minus(amount).toNumber(); + + return { + filteredUtxos, + inputs: neededUtxos, + outputs, + size: sizeInfo.txVBytes, + fee: newFee, + isCustomFeeRate: true + }; + } + + return { + filteredUtxos, + inputs: neededUtxos, + outputs, + size: sizeInfo.txVBytes, + fee, + isCustomFeeRate: false + }; +} + +export function filterOutPendingTxsUtxos (utxos: UtxoResponseItem[]): UtxoResponseItem[] { + return utxos.filter((utxo) => utxo.status.confirmed); +} + +export function filteredOutTxsUtxos (allTxsUtxos: UtxoResponseItem[], filteredOutTxsUtxos: UtxoResponseItem[]): UtxoResponseItem[] { + if (!filteredOutTxsUtxos.length) { + return allTxsUtxos; + } + + const listFilterOut = filteredOutTxsUtxos.map((utxo) => { + return `${utxo.txid}:${utxo.vout}`; + }); + + return allTxsUtxos.filter((element) => !listFilterOut.includes(`${element.txid}:${element.vout}`)); +} + +export async function getRuneUtxos (bitcoinApi: _BitcoinApi, address: string) { + const responseRuneUtxos = await bitcoinApi.api.getRuneUtxos(address); + + const runeUtxos: UtxoResponseItem[] = []; + + responseRuneUtxos.forEach((responseRuneUtxo) => { + const txid = responseRuneUtxo.txid; + const vout = responseRuneUtxo.vout; + const utxoValue = responseRuneUtxo.satoshi; + + if (txid && vout && utxoValue) { + const item = { + txid, + vout, + status: { + confirmed: true // not use in filter out rune utxos + }, + value: utxoValue + } as UtxoResponseItem; + + runeUtxos.push(item); + } + }); + + return runeUtxos; +} + +export async function getInscriptionUtxos (bitcoinApi: _BitcoinApi, address: string) { + try { + const inscriptions = await bitcoinApi.api.getAddressInscriptions(address); + + return inscriptions.map((inscription) => { + const [txid, vout] = inscription.output.split(':'); + + return { + txid, + vout: parseInt(vout), + status: { + confirmed: true, // not use in filter out inscription utxos + block_height: inscription.genesis_block_height, + block_hash: inscription.genesis_block_hash, + block_time: inscription.genesis_timestamp + }, + value: parseInt(inscription.value) + } as UtxoResponseItem; + }); + } catch (e) { + return []; + } +} diff --git a/packages/extension-base/src/utils/fee/transfer.ts b/packages/extension-base/src/utils/fee/transfer.ts index 8dd149e21a8..6b03ad0810a 100644 --- a/packages/extension-base/src/utils/fee/transfer.ts +++ b/packages/extension-base/src/utils/fee/transfer.ts @@ -6,6 +6,7 @@ import { AmountData } from '@subwallet/extension-base/background/KoniTypes'; import { _SUPPORT_TOKEN_PAY_FEE_GROUP, XCM_FEE_RATIO } from '@subwallet/extension-base/constants'; import { _isSnowBridgeXcm } from '@subwallet/extension-base/core/substrate/xcm-parser'; import { DEFAULT_CARDANO_TTL_OFFSET } from '@subwallet/extension-base/services/balance-service/helpers/subscribe/cardano/consts'; +import { createBitcoinTransaction } from '@subwallet/extension-base/services/balance-service/transfer/bitcoin-transfer'; import { createCardanoTransaction } from '@subwallet/extension-base/services/balance-service/transfer/cardano-transfer'; import { getERC20TransactionObject, getEVMTransactionObject } from '@subwallet/extension-base/services/balance-service/transfer/smart-contract'; import { createSubstrateExtrinsic } from '@subwallet/extension-base/services/balance-service/transfer/token'; @@ -16,8 +17,8 @@ import { isAvailChainBridge } from '@subwallet/extension-base/services/balance-s import { _isPolygonChainBridge } from '@subwallet/extension-base/services/balance-service/transfer/xcm/polygonBridge'; import { _isPosChainBridge } from '@subwallet/extension-base/services/balance-service/transfer/xcm/posBridge'; import { estimateXcmFee } from '@subwallet/extension-base/services/balance-service/transfer/xcm/utils'; -import { _CardanoApi, _EvmApi, _SubstrateApi, _TonApi } from '@subwallet/extension-base/services/chain-service/types'; -import { _getAssetDecimals, _getContractAddressOfToken, _isChainCardanoCompatible, _isChainEvmCompatible, _isChainTonCompatible, _isLocalToken, _isNativeToken, _isPureEvmChain, _isTokenEvmSmartContract, _isTokenTransferredByCardano, _isTokenTransferredByEvm, _isTokenTransferredByTon } from '@subwallet/extension-base/services/chain-service/utils'; +import { _BitcoinApi, _CardanoApi, _EvmApi, _SubstrateApi, _TonApi } from '@subwallet/extension-base/services/chain-service/types'; +import { _getAssetDecimals, _getContractAddressOfToken, _isChainBitcoinCompatible, _isChainCardanoCompatible, _isChainEvmCompatible, _isChainTonCompatible, _isLocalToken, _isNativeToken, _isPureEvmChain, _isTokenEvmSmartContract, _isTokenTransferredByBitcoin, _isTokenTransferredByCardano, _isTokenTransferredByEvm, _isTokenTransferredByTon } from '@subwallet/extension-base/services/chain-service/utils'; import { calculateToAmountByReservePool, FEE_COVERAGE_PERCENTAGE_SPECIAL_CASE } from '@subwallet/extension-base/services/fee-service/utils'; import { getHydrationRate } from '@subwallet/extension-base/services/fee-service/utils/tokenPayFee'; import { isCardanoTransaction, isTonTransaction } from '@subwallet/extension-base/services/transaction-service/helpers'; @@ -26,7 +27,9 @@ import { EvmEIP1559FeeOption, FeeChainType, FeeDetail, FeeInfo, SubstrateTipInfo import { ResponseSubscribeTransfer } from '@subwallet/extension-base/types/balance/transfer'; import { BN_ZERO } from '@subwallet/extension-base/utils'; import { isCardanoAddress, isTonAddress } from '@subwallet/keyring'; +import { isBitcoinAddress } from '@subwallet/keyring/utils/address/validate'; import BigN from 'bignumber.js'; +import * as bitcoin from 'bitcoinjs-lib'; import { TransactionConfig } from 'web3-core'; import { SubmittableExtrinsic } from '@polkadot/api/types'; @@ -37,6 +40,7 @@ import { combineEthFee, combineSubstrateFee } from './combine'; export interface CalculateMaxTransferable extends TransactionFee { address: string; + to?: string; value: string; srcToken: _ChainAsset; destToken?: _ChainAsset; @@ -46,6 +50,7 @@ export interface CalculateMaxTransferable extends TransactionFee { evmApi: _EvmApi; tonApi: _TonApi; cardanoApi: _CardanoApi; + bitcoinApi: _BitcoinApi; isTransferLocalTokenAndPayThatTokenAsFee: boolean; isTransferNativeTokenAndPayLocalTokenAsFee: boolean; nativeToken: _ChainAsset; @@ -69,6 +74,8 @@ export const detectTransferTxType = (srcToken: _ChainAsset, srcChain: _ChainInfo return 'ton'; } else if (_isChainCardanoCompatible(srcChain) && _isTokenTransferredByCardano(srcToken)) { return 'cardano'; + } else if (_isChainBitcoinCompatible(srcChain) && _isTokenTransferredByBitcoin(srcToken)) { + return 'bitcoin'; } else { return 'substrate'; } @@ -99,7 +106,7 @@ export const calculateMaxTransferable = async (id: string, request: CalculateMax }; export const calculateTransferMaxTransferable = async (id: string, request: CalculateMaxTransferable, freeBalance: AmountData, fee: FeeInfo): Promise => { - const { address, cardanoApi, destChain, evmApi, feeCustom, feeOption, isTransferLocalTokenAndPayThatTokenAsFee, isTransferNativeTokenAndPayLocalTokenAsFee, nativeToken, srcChain, srcToken, substrateApi, tonApi, value } = request; + const { address, bitcoinApi, cardanoApi, destChain, evmApi, feeCustom, feeOption, isTransferLocalTokenAndPayThatTokenAsFee, isTransferNativeTokenAndPayLocalTokenAsFee, nativeToken, srcChain, srcToken, substrateApi, to, tonApi, value } = request; const feeChainType = fee.type; let estimatedFee: string; let feeOptions: FeeDetail; @@ -168,6 +175,19 @@ export const calculateTransferMaxTransferable = async (id: string, request: Calc cardanoApi, nativeTokenInfo: nativeToken }); + } else if (isBitcoinAddress(address) && _isTokenTransferredByBitcoin(srcToken)) { + const network = srcChain.isTestnet ? bitcoin.networks.testnet : bitcoin.networks.bitcoin; + + [transaction] = await createBitcoinTransaction({ + chain: srcChain.slug, + from: address, + to: to || address, + value, + feeInfo: fee, + transferAll: false, + bitcoinApi, + network: network + }); } else { [transaction] = await createSubstrateExtrinsic({ transferAll: false, @@ -221,6 +241,15 @@ export const calculateTransferMaxTransferable = async (id: string, request: Calc ...fee, estimatedFee }; + } else if (feeChainType === 'bitcoin') { + // Calculate fee for bitcoin transaction + // TODO: Support maxTransferable for bitcoin + estimatedFee = '0'; + feeOptions = { + ...fee, + vSize: 0, + estimatedFee + }; } else { if (transaction) { if (isTonTransaction(transaction)) { @@ -261,6 +290,12 @@ export const calculateTransferMaxTransferable = async (id: string, request: Calc estimatedFee, gasLimit: '0' }; + } else if (fee.type === 'bitcoin') { + feeOptions = { + ...fee, + estimatedFee, + vSize: 0 + }; } else { feeOptions = { ...fee, @@ -434,6 +469,12 @@ export const calculateXcmMaxTransferable = async (id: string, request: Calculate ...fee, estimatedFee }; + } else if (feeChainType === 'bitcoin') { + feeOptions = { + ...fee, + estimatedFee, + vSize: 0 + }; } else { // Not implemented yet estimatedFee = '0'; @@ -451,6 +492,12 @@ export const calculateXcmMaxTransferable = async (id: string, request: Calculate estimatedFee, gasLimit: '0' }; + } else if (fee.type === 'bitcoin') { + feeOptions = { + ...fee, + estimatedFee, + vSize: 0 + }; } else { feeOptions = { ...fee, diff --git a/packages/extension-base/src/utils/index.ts b/packages/extension-base/src/utils/index.ts index 4f338a9f559..a851d040fc3 100644 --- a/packages/extension-base/src/utils/index.ts +++ b/packages/extension-base/src/utils/index.ts @@ -5,10 +5,11 @@ import { _ChainInfo } from '@subwallet/chain-list/types'; import { CrowdloanParaState, NetworkJson } from '@subwallet/extension-base/background/KoniTypes'; import { AccountAuthType } from '@subwallet/extension-base/background/types'; import { getRandomIpfsGateway, SUBWALLET_IPFS } from '@subwallet/extension-base/koni/api/nft/config'; -import { _isChainCardanoCompatible, _isChainEvmCompatible, _isChainSubstrateCompatible, _isChainTonCompatible } from '@subwallet/extension-base/services/chain-service/utils'; +import { _isChainBitcoinCompatible, _isChainCardanoCompatible, _isChainEvmCompatible, _isChainSubstrateCompatible, _isChainTonCompatible } from '@subwallet/extension-base/services/chain-service/utils'; import { AccountJson } from '@subwallet/extension-base/types'; import { reformatAddress } from '@subwallet/extension-base/utils/account'; import { decodeAddress, encodeAddress, isCardanoAddress, isTonAddress } from '@subwallet/keyring'; +import { isBitcoinAddress } from '@subwallet/keyring/utils/address/validate'; import { t } from 'i18next'; import { assert, BN, hexToU8a, isHex } from '@polkadot/util'; @@ -306,8 +307,9 @@ export function isAddressAndChainCompatible (address: string, chain: _ChainInfo) const isTonCompatible = isTonAddress(address) && _isChainTonCompatible(chain); const isSubstrateCompatible = !isEthereumAddress(address) && !isTonAddress(address) && _isChainSubstrateCompatible(chain); // todo: need isSubstrateAddress util function to check exactly const isCardanoCompatible = isCardanoAddress(address) && _isChainCardanoCompatible(chain); + const isBitcoinCompatible = isBitcoinAddress(address) && _isChainBitcoinCompatible(chain); - return isEvmCompatible || isSubstrateCompatible || isTonCompatible || isCardanoCompatible; + return isEvmCompatible || isSubstrateCompatible || isTonCompatible || isCardanoCompatible || isBitcoinCompatible; } export function getDomainFromUrl (url: string): string { @@ -410,3 +412,4 @@ export * from './promise'; export * from './registry'; export * from './swap'; export * from './translate'; +export * from './bitcoin'; diff --git a/packages/extension-koni-ui/src/Popup/Account/AccountDetail/index.tsx b/packages/extension-koni-ui/src/Popup/Account/AccountDetail/index.tsx index 783e1562415..7219bf991c4 100644 --- a/packages/extension-koni-ui/src/Popup/Account/AccountDetail/index.tsx +++ b/packages/extension-koni-ui/src/Popup/Account/AccountDetail/index.tsx @@ -3,7 +3,7 @@ import { NotificationType } from '@subwallet/extension-base/background/KoniTypes'; import { AccountActions, AccountProxy, AccountProxyType } from '@subwallet/extension-base/types'; -import { AccountProxyTypeTag, CloseIcon, Layout, PageWrapper } from '@subwallet/extension-koni-ui/components'; +import { AccountChainTypeLogos, AccountProxyTypeTag, CloseIcon, Layout, PageWrapper } from '@subwallet/extension-koni-ui/components'; import { FilterTabItemType, FilterTabs } from '@subwallet/extension-koni-ui/components/FilterTabs'; import { WalletModalContext } from '@subwallet/extension-koni-ui/contexts/WalletModalContextProvider'; import { useDefaultNavigate, useGetAccountProxyById, useNotification } from '@subwallet/extension-koni-ui/hooks'; @@ -14,7 +14,7 @@ import { FormCallbacks, FormFieldData } from '@subwallet/extension-koni-ui/types import { convertFieldToObject } from '@subwallet/extension-koni-ui/utils/form/form'; import { Button, Form, Icon, Input } from '@subwallet/react-ui'; import CN from 'classnames'; -import { CircleNotch, Export, FloppyDiskBack, GitMerge, Trash } from 'phosphor-react'; +import { Export, GitMerge, Trash } from 'phosphor-react'; import { RuleObject } from 'rc-field-form/lib/interface'; import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -95,7 +95,6 @@ const Component: React.FC = ({ accountProxy, onBack, requestView const [deleting, setDeleting] = useState(false); // @ts-ignore const [deriving, setDeriving] = useState(false); - const [saving, setSaving] = useState(false); const filterTabItems = useMemo(() => { const result = [ @@ -211,7 +210,6 @@ const Component: React.FC = ({ accountProxy, onBack, requestView if (changeMap[FormFieldName.NAME]) { clearTimeout(saveTimeOutRef.current); - setSaving(true); const isValidForm = form.getFieldsError().every((field) => !field.errors.length); @@ -219,8 +217,6 @@ const Component: React.FC = ({ accountProxy, onBack, requestView saveTimeOutRef.current = setTimeout(() => { form.submit(); }, 1000); - } else { - setSaving(false); } } }, [form]); @@ -230,25 +226,18 @@ const Component: React.FC = ({ accountProxy, onBack, requestView const name = values[FormFieldName.NAME]; if (name === accountProxy.name) { - setSaving(false); - return; } const accountProxyId = accountProxy.id; if (!accountProxyId) { - setSaving(false); - return; } editAccount(accountProxyId, name.trim()) .catch((error: Error) => { form.setFields([{ name: FormFieldName.NAME, errors: [error.message] }]); - }) - .finally(() => { - setSaving(false); }); }, [accountProxy.id, accountProxy.name, form]); @@ -427,10 +416,9 @@ const Component: React.FC = ({ accountProxy, onBack, requestView onBlur={form.submit} placeholder={t('Account name')} suffix={( - )} /> @@ -526,6 +514,12 @@ const AccountDetail = styled(Wrapper)(({ theme: { token } }: Props) => { gap: token.sizeSM }, + '.__account-item-chain-type-logos': { + minHeight: 20, + marginRight: 12, + marginLeft: 12 + }, + '.account-detail-form, .derivation-info-form': { paddingTop: token.padding, paddingLeft: token.padding, diff --git a/packages/extension-koni-ui/src/Popup/Account/RestoreJson/index.tsx b/packages/extension-koni-ui/src/Popup/Account/RestoreJson/index.tsx index 2c9d4ddcef0..87efd7ff26f 100644 --- a/packages/extension-koni-ui/src/Popup/Account/RestoreJson/index.tsx +++ b/packages/extension-koni-ui/src/Popup/Account/RestoreJson/index.tsx @@ -390,7 +390,7 @@ const Component: React.FC = ({ className }: Props) => { return (
{(item as ListItemGroupLabel).groupLabel}
@@ -398,17 +398,15 @@ const Component: React.FC = ({ className }: Props) => { } return ( - <> - - + ); }, [accountProxiesSelected, onSelect, submitting]); diff --git a/packages/extension-koni-ui/src/Popup/BuyTokens.tsx b/packages/extension-koni-ui/src/Popup/BuyTokens.tsx index 81f3e198902..30e7793ecdb 100644 --- a/packages/extension-koni-ui/src/Popup/BuyTokens.tsx +++ b/packages/extension-koni-ui/src/Popup/BuyTokens.tsx @@ -8,7 +8,7 @@ import { detectTranslate, isAccountAll } from '@subwallet/extension-base/utils'; import { AccountAddressSelector, baseServiceItems, Layout, PageWrapper, ServiceItem } from '@subwallet/extension-koni-ui/components'; import { ServiceSelector } from '@subwallet/extension-koni-ui/components/Field/BuyTokens/ServiceSelector'; import { TokenSelector } from '@subwallet/extension-koni-ui/components/Field/TokenSelector'; -import { useAssetChecker, useDefaultNavigate, useGetAccountTokenBalance, useGetChainSlugsByAccount, useNotification, useReformatAddress, useTranslation } from '@subwallet/extension-koni-ui/hooks'; +import { useAssetChecker, useCoreCreateReformatAddress, useDefaultNavigate, useGetAccountTokenBalance, useGetChainSlugsByCurrentAccountProxy, useNotification, useTranslation } from '@subwallet/extension-koni-ui/hooks'; import { RootState } from '@subwallet/extension-koni-ui/stores'; import { AccountAddressItemType, CreateBuyOrderFunction, ThemeProps } from '@subwallet/extension-koni-ui/types'; import { TokenSelectorItemType } from '@subwallet/extension-koni-ui/types/field'; @@ -79,8 +79,8 @@ function Component ({ className, currentAccountProxy }: ComponentProps) { const getAccountTokenBalance = useGetAccountTokenBalance(); const checkAsset = useAssetChecker(); - const allowedChains = useGetChainSlugsByAccount(); - const getReformatAddress = useReformatAddress(); + const allowedChains = useGetChainSlugsByCurrentAccountProxy(); + const getReformatAddress = useCoreCreateReformatAddress(); const fixedTokenSlug = useMemo((): string | undefined => { if (currentSymbol) { diff --git a/packages/extension-koni-ui/src/Popup/Confirmations/index.tsx b/packages/extension-koni-ui/src/Popup/Confirmations/index.tsx index ffff2f4edcd..c6168551515 100644 --- a/packages/extension-koni-ui/src/Popup/Confirmations/index.tsx +++ b/packages/extension-koni-ui/src/Popup/Confirmations/index.tsx @@ -1,7 +1,7 @@ // Copyright 2019-2022 @subwallet/extension-koni-ui authors & contributors // SPDX-License-Identifier: Apache-2.0 -import { ConfirmationDefinitions, ConfirmationDefinitionsCardano, ExtrinsicType } from '@subwallet/extension-base/background/KoniTypes'; +import { ConfirmationDefinitions, ConfirmationDefinitionsBitcoin, ConfirmationDefinitionsCardano, ExtrinsicType } from '@subwallet/extension-base/background/KoniTypes'; import { AuthorizeRequest, MetadataRequest, SigningRequest } from '@subwallet/extension-base/background/types'; import { WalletConnectNotSupportRequest, WalletConnectSessionRequest } from '@subwallet/extension-base/services/wallet-connect-service/types'; import { AccountJson, ProcessType } from '@subwallet/extension-base/types'; @@ -33,6 +33,11 @@ const titleMap: Record = { evmSignatureRequest: detectTranslate('Signature request'), cardanoSignatureRequest: detectTranslate('Signature request'), cardanoSignTransactionRequest: detectTranslate('Transaction request'), + bitcoinSignatureRequest: detectTranslate('Signature request'), + bitcoinSendTransactionRequest: detectTranslate('Transaction request'), + bitcoinSendTransactionRequestAfterConfirmation: detectTranslate('Transaction request'), + bitcoinWatchTransactionRequest: detectTranslate('Transaction request'), + bitcoinSignPsbtRequest: detectTranslate('Sign PSBT request'), metadataRequest: detectTranslate('Update metadata'), signingRequest: detectTranslate('Signature request'), connectWCRequest: detectTranslate('WalletConnect'), @@ -114,6 +119,12 @@ const Component = function ({ className }: Props) { account = findAccountByAddress(accounts, request.payload.address) || undefined; canSign = request.payload.canSign; isMessage = false; + } else if (['bitcoinSignatureRequest', 'bitcoinSendTransactionRequest', 'bitcoinWatchTransactionRequest', 'bitcoinSignPsbtRequest', 'bitcoinSendTransactionRequestAfterConfirmation'].includes(confirmation.type)) { + const request = confirmation.item as ConfirmationDefinitionsBitcoin['bitcoinSignatureRequest' | 'bitcoinSendTransactionRequest' | 'bitcoinWatchTransactionRequest' | 'bitcoinSendTransactionRequestAfterConfirmation'][0]; + + account = request.payload.account; + canSign = request.payload.canSign; + isMessage = confirmation.type === 'bitcoinSignatureRequest'; } const signMode = getSignMode(account); @@ -200,6 +211,28 @@ const Component = function ({ className }: Props) { type={confirmation.type} /> ); + + // case 'bitcoinSignatureRequest': + // return ( + // + // ); + // case 'bitcoinSignPsbtRequest': + // return ( + // + // ); + // case 'bitcoinSendTransactionRequestAfterConfirmation': + // return ( + // + // ); case 'authorizeRequest': return ( diff --git a/packages/extension-koni-ui/src/Popup/Confirmations/parts/Sign/Bitcoin.tsx b/packages/extension-koni-ui/src/Popup/Confirmations/parts/Sign/Bitcoin.tsx new file mode 100644 index 00000000000..0441c8eb580 --- /dev/null +++ b/packages/extension-koni-ui/src/Popup/Confirmations/parts/Sign/Bitcoin.tsx @@ -0,0 +1,318 @@ +// Copyright 2019-2022 @subwallet/extension-koni-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { BitcoinSignatureRequest, BitcoinSignPsbtRequest, ConfirmationDefinitionsBitcoin, ConfirmationResult, ExtrinsicType } from '@subwallet/extension-base/background/KoniTypes'; +import { RequestSubmitTransferWithId } from '@subwallet/extension-base/types/balance/transfer'; +import { wait } from '@subwallet/extension-base/utils'; +import { CONFIRMATION_QR_MODAL } from '@subwallet/extension-koni-ui/constants'; +import { useNotification, useUnlockChecker } from '@subwallet/extension-koni-ui/hooks'; +import { completeConfirmationBitcoin, makeBitcoinDappTransferConfirmation, makePSBTTransferAfterConfirmation } from '@subwallet/extension-koni-ui/messaging'; +import { AccountSignMode, BitcoinSignatureSupportType, PhosphorIcon, SigData, ThemeProps } from '@subwallet/extension-koni-ui/types'; +import { getSignMode, removeTransactionPersist } from '@subwallet/extension-koni-ui/utils'; +import { Button, Icon, ModalContext } from '@subwallet/react-ui'; +import CN from 'classnames'; +import { CheckCircle, QrCode, Swatches, Wallet, XCircle } from 'phosphor-react'; +import React, { useCallback, useContext, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; + +import { ScanSignature } from '../Qr'; + +interface Props extends ThemeProps { + id: string; + type: BitcoinSignatureSupportType; + payload: ConfirmationDefinitionsBitcoin[BitcoinSignatureSupportType][0]; + extrinsicType?: ExtrinsicType; + editedPayload?: RequestSubmitTransferWithId; + canSign?: boolean; +} + +const handleConfirm = async (type: BitcoinSignatureSupportType, id: string, payload: string) => { + return await completeConfirmationBitcoin(type, { + id, + isApproved: true, + payload + } as ConfirmationResult); +}; + +const handleCancel = async (type: BitcoinSignatureSupportType, id: string) => { + return await completeConfirmationBitcoin(type, { + id, + isApproved: false + } as ConfirmationResult); +}; + +const handleSignature = async (type: BitcoinSignatureSupportType, id: string, signature: string) => { + return await completeConfirmationBitcoin(type, { + id, + isApproved: true, + payload: signature + } as ConfirmationResult); +}; + +const Component: React.FC = (props: Props) => { + const { canSign, className, editedPayload, extrinsicType, id, payload, type } = props; + // const { payload: { hashPayload } } = payload; + const { account } = (payload.payload as BitcoinSignatureRequest); + // TODO: [Review] Error eslint + // const chainId = (payload.payload as EvmSendTransactionRequest)?.chainId || 1; + + const { t } = useTranslation(); + const notify = useNotification(); + + const { activeModal } = useContext(ModalContext); + + // const chain = useGetChainInfoByChainId(chainId); + + const checkUnlock = useUnlockChecker(); + const signMode = useMemo(() => getSignMode(account), [account]); + // TODO: [Review] type generic_ledger or legacy_ledger + // const isLedger = useMemo(() => signMode === AccountSignMode.GENERIC_LEDGER, [signMode]); + // const isMessage = isBitcoinMessage(payload); + + const [loading, setLoading] = useState(false); + + // const { error: ledgerError, + // isLoading: isLedgerLoading, + // isLocked, + // ledger, + // refresh: refreshLedger, + // signMessage: ledgerSignMessage, + // signTransaction: ledgerSignTransaction, + // warning: ledgerWarning } = useLedger(chain?.slug, isLedger); + + // const isLedgerConnected = useMemo(() => !isLocked && !isLedgerLoading && !!ledger, [ + // isLedgerLoading, + // isLocked, + // ledger + // ]); + + const approveIcon = useMemo((): PhosphorIcon => { + switch (signMode) { + case AccountSignMode.QR: + return QrCode; + case AccountSignMode.GENERIC_LEDGER: + return Swatches; + case AccountSignMode.INJECTED: + return Wallet; + default: + return CheckCircle; + } + }, [signMode]); + + // Handle buttons actions + const onCancel = useCallback(() => { + setLoading(true); + handleCancel(type, id).finally(() => { + setLoading(false); + }); + }, [id, type]); + + const onApprovePassword = useCallback(() => { + setLoading(true); + + const promise = async () => { + if (type === 'bitcoinSendTransactionRequestAfterConfirmation' && editedPayload) { + await makeBitcoinDappTransferConfirmation(editedPayload); + } else if (type === 'bitcoinSignPsbtRequest') { + const { payload: { account, broadcast, network, psbt, to, tokenSlug, txInput, txOutput, value } } = payload.payload as BitcoinSignPsbtRequest; + + if (broadcast) { + await makePSBTTransferAfterConfirmation({ id, chain: network, txOutput, txInput, tokenSlug, psbt, from: account, to, value }); + } else { + await wait(1000); + } + } else { + await wait(1000); + } + }; + + promise().then(() => { + handleConfirm(type, id, '').finally(() => { + setLoading(false); + }); + }) + .catch((error) => { + console.error(error); + notify({ + message: t((error as Error).message), + type: 'error', + duration: 8 + }); + }) + .finally(() => { + setLoading(false); + }); + }, [editedPayload, id, notify, payload.payload, t, type]); + + const onApproveSignature = useCallback((signature: SigData) => { + setLoading(true); + + setTimeout(() => { + handleSignature(type, id, signature.signature) + .catch((e) => { + console.log(e); + }) + .finally(() => { + setLoading(false); + }); + }, 300); + }, [id, type]); + + const onConfirmQr = useCallback(() => { + activeModal(CONFIRMATION_QR_MODAL); + }, [activeModal]); + + // const onConfirmLedger = useCallback(() => { + // if (!hashPayload) { + // return; + // } + // + // if (!isLedgerConnected || !ledger) { + // refreshLedger(); + // + // return; + // } + // + // setLoading(true); + // + // setTimeout(() => { + // // TODO: Review metadata of ledgerSignTransaction + // const signPromise = isMessage ? ledgerSignMessage(u8aToU8a(hashPayload), account?.accountIndex, account?.addressOffset) : ledgerSignTransaction(hexToU8a(hashPayload), new Uint8Array(0), account?.accountIndex, account?.addressOffset); + // + // signPromise + // .then(({ signature }) => { + // onApproveSignature({ signature }); + // }) + // .catch((e: Error) => { + // console.log(e); + // setLoading(false); + // }); + // }); + // }, [account?.accountIndex, account?.addressOffset, hashPayload, isLedgerConnected, isMessage, ledger, ledgerSignMessage, ledgerSignTransaction, onApproveSignature, refreshLedger]); + + const onConfirmInject = useCallback(() => { + console.error('Not implemented yet'); + // if (evmWallet) { + // let promise: Promise<`0x${string}`>; + // + // if (isMessage) { + // promise = evmWallet.request<`0x${string}`>({ method: payload.payload.type, params: [account.address, payload.payload.payload] }); + // } else { + // promise = new Promise<`0x${string}`>((resolve, reject) => { + // const { account, canSign, hashPayload, ...transactionConfig } = payload.payload; + // + // evmWallet.request({ + // method: 'wallet_switchEthereumChain', + // params: [{ chainId: chainId.toString(16) }] + // }) + // .then(() => evmWallet.request<`0x${string}`>({ + // method: 'eth_sendTransaction', + // params: [transactionConfig] + // })) + // .then((value) => { + // resolve(value); + // }) + // .catch(reject); + // }); + // } + // + // setLoading(true); + // promise + // .then((signature) => { + // onApproveSignature({ signature }); + // }) + // .catch((e) => { + // console.error(e); + // }) + // .finally(() => { + // setLoading(false); + // }); + // } + }, []); + + const onConfirm = useCallback(() => { + removeTransactionPersist(extrinsicType); + + switch (signMode) { + case AccountSignMode.QR: + onConfirmQr(); + break; + // case AccountSignMode.GENERIC_LEDGER: + // onConfirmLedger(); + // break; + case AccountSignMode.INJECTED: + onConfirmInject(); + break; + default: + checkUnlock().then(() => { + onApprovePassword(); + }).catch(() => { + // Unlock is cancelled + }); + } + }, [checkUnlock, extrinsicType, onConfirmInject, onApprovePassword, onConfirmQr, signMode]); + + // useEffect(() => { + // !!ledgerError && notify({ + // message: ledgerError, + // type: 'error' + // }); + // }, [ledgerError, notify]); + + // useEffect(() => { + // !!ledgerWarning && notify({ + // message: ledgerWarning, + // type: 'warning' + // }); + // }, [ledgerWarning, notify]); + + return ( +
+ + + {/* { */} + {/* signMode === AccountSignMode.QR && ( */} + {/* */} + {/* */} + {/* */} + {/* ) */} + {/* } */} + {signMode === AccountSignMode.QR && } +
+ ); +}; + +const BitcoinSignArea = styled(Component)(({ theme: { token } }: Props) => { + return {}; +}); + +export default BitcoinSignArea; diff --git a/packages/extension-koni-ui/src/Popup/Confirmations/parts/Sign/index.tsx b/packages/extension-koni-ui/src/Popup/Confirmations/parts/Sign/index.tsx index 6cebc212e26..27fda5cd6c8 100644 --- a/packages/extension-koni-ui/src/Popup/Confirmations/parts/Sign/index.tsx +++ b/packages/extension-koni-ui/src/Popup/Confirmations/parts/Sign/index.tsx @@ -5,3 +5,4 @@ export { default as EvmSignArea } from './Evm'; export { default as SubstrateSignArea } from './Substrate'; export { default as TonSignArea } from './Ton'; export { default as CardanoSignArea } from './Cardano'; +export { default as BitcoinSignArea } from './Bitcoin'; diff --git a/packages/extension-koni-ui/src/Popup/Confirmations/variants/BitcoinSendTransactionRequestConfirmation.tsx b/packages/extension-koni-ui/src/Popup/Confirmations/variants/BitcoinSendTransactionRequestConfirmation.tsx new file mode 100644 index 00000000000..daa033ac1af --- /dev/null +++ b/packages/extension-koni-ui/src/Popup/Confirmations/variants/BitcoinSendTransactionRequestConfirmation.tsx @@ -0,0 +1,286 @@ +// Copyright 2019-2022 @subwallet/extension-koni-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// import { _ChainAsset } from '@subwallet/chain-list/types'; +// import { BitcoinSendTransactionRequest, ConfirmationsQueueItem } from '@subwallet/extension-base/background/KoniTypes'; +// import { BitcoinFeeDetail, RequestSubmitTransferWithId, ResponseSubscribeTransferConfirmation, TransactionFee } from '@subwallet/extension-base/types'; +// import { getDomainFromUrl } from '@subwallet/extension-base/utils'; +// import { BitcoinFeeSelector, MetaInfo } from '@subwallet/extension-koni-ui/components'; +// import { RenderFieldNodeParams } from '@subwallet/extension-koni-ui/components/Field/TransactionFee/BitcoinFeeSelector'; +// import { useGetAccountByAddress, useNotification } from '@subwallet/extension-koni-ui/hooks'; +// import { cancelSubscription, subscribeTransferWhenConfirmation } from '@subwallet/extension-koni-ui/messaging'; +// import { BitcoinSignArea } from '@subwallet/extension-koni-ui/Popup/Confirmations/parts'; +// import { RootState } from '@subwallet/extension-koni-ui/stores'; +// import { BitcoinSignatureSupportType, ThemeProps } from '@subwallet/extension-koni-ui/types'; +// import { ActivityIndicator, Button, Icon, Number } from '@subwallet/react-ui'; +// import BigN from 'bignumber.js'; +// import CN from 'classnames'; +// import { PencilSimpleLine } from 'phosphor-react'; +// import React, { useCallback, useEffect, useMemo, useState } from 'react'; +// import { useTranslation } from 'react-i18next'; +// import { useSelector } from 'react-redux'; +// import styled from 'styled-components'; +// +// interface Props extends ThemeProps { +// type: BitcoinSignatureSupportType +// request: ConfirmationsQueueItem +// } +// +// const convertToBigN = (num: BitcoinSendTransactionRequest['value']): string | number | undefined => { +// if (typeof num === 'object') { +// return num.toNumber(); +// } else { +// return num; +// } +// }; +// +// function Component ({ className, request, type }: Props) { +// const { id, payload: { account, networkKey, to, tokenSlug, value } } = request; +// const { t } = useTranslation(); +// const transferAmountValue = useMemo(() => value?.toString() as string, [value]); +// const fromValue = useMemo(() => account.address, [account.address]); +// const toValue = useMemo(() => to ? to[0].address : '', [to]); +// const chainValue = useMemo(() => networkKey as string, [networkKey]); +// const assetValue = useMemo(() => tokenSlug as string, [tokenSlug]); +// +// const [transactionInfo, setTransactionInfo] = useState({ +// id, +// chain: networkKey as string, +// from: account.address, +// to: toValue, +// tokenSlug: tokenSlug as string, +// transferAll: false, +// value: value?.toString() || '0' +// }); +// const [isFetchingInfo, setIsFetchingInfo] = useState(false); +// const [transferInfo, setTransferInfo] = useState(); +// const [transactionFeeInfo, setTransactionFeeInfo] = useState(undefined); +// const [isErrorTransaction, setIsErrorTransaction] = useState(false); +// const notify = useNotification(); +// const assetRegistry = useSelector((root: RootState) => root.assetRegistry.assetRegistry); +// +// const assetInfo: _ChainAsset | undefined = useMemo(() => { +// return assetRegistry[assetValue]; +// }, [assetRegistry, assetValue]); +// +// const recipient = useGetAccountByAddress(toValue); +// +// // console.log(transactionRequest); +// const amount = useMemo((): number => { +// return new BigN(convertToBigN(request.payload.value) || 0).toNumber(); +// }, [request.payload.value]); +// +// const renderFeeSelectorNode = useCallback((params: RenderFieldNodeParams) => { +// return ( +// +// {params.isLoading +// ? ( +//
+// +//
+// ) +// : ( +//
+// +//
+// )} +//
+// ); +// }, [t]); +// +// useEffect(() => { +// setTransactionInfo((prevState) => ({ ...prevState, ...transactionFeeInfo })); +// }, [transactionFeeInfo]); +// +// useEffect(() => { +// let cancel = false; +// let id = ''; +// let timeout: NodeJS.Timeout; +// +// setIsFetchingInfo(true); +// +// const callback = (transferInfo: ResponseSubscribeTransferConfirmation) => { +// if (transferInfo.error) { +// notify({ +// message: t(transferInfo.error), +// type: 'error', +// duration: 8 +// }); +// setIsErrorTransaction(true); +// } else if (!cancel) { +// setTransferInfo(transferInfo); +// id = transferInfo.id; +// } else { +// cancelSubscription(transferInfo.id).catch(console.error); +// } +// }; +// +// if (fromValue && assetValue) { +// timeout = setTimeout(() => { +// subscribeTransferWhenConfirmation({ +// address: fromValue, +// chain: chainValue, +// token: assetValue, +// destChain: chainValue, +// feeOption: transactionFeeInfo?.feeOption, +// feeCustom: transactionFeeInfo?.feeCustom, +// value: transferAmountValue || '0', +// transferAll: false, +// to: toValue +// }, callback) +// .then(callback) +// .catch((e) => { +// console.error(e); +// notify({ +// message: t(e), +// type: 'error', +// duration: 8 +// }); +// setIsErrorTransaction(true); +// setTransferInfo(undefined); +// }) +// .finally(() => { +// setIsFetchingInfo(false); +// }); +// }, 100); +// } +// +// return () => { +// cancel = true; +// clearTimeout(timeout); +// id && cancelSubscription(id).catch(console.error); +// }; +// }, [assetRegistry, assetValue, chainValue, fromValue, toValue, transactionFeeInfo, transferAmountValue, notify, t]); +// +// return ( +// <> +//
+//
{getDomainFromUrl(request.url)}
+// +// +// +// +// +// +// +// +// +// +// +// {!isErrorTransaction && } +// +// +// {/* {!!transaction.estimateFee?.tooHigh && ( */} +// {/* */} +// {/* )} */} +//
+// +// +// ); +// } +// +// const BitcoinSendTransactionRequestConfirmation = styled(Component)(({ theme: { token } }: ThemeProps) => ({ +// '&.confirmation-content.confirmation-content': { +// display: 'block' +// }, +// +// '.__origin-url': { +// marginBottom: token.margin +// }, +// +// '.__fee-editor-loading-wrapper': { +// minWidth: 40, +// height: 40, +// display: 'flex', +// alignItems: 'center', +// justifyContent: 'center' +// }, +// +// '.__fee-editor.__fee-editor.__fee-editor': { +// marginTop: 4, +// marginRight: -10 +// }, +// +// '.__fee-editor-value-wrapper': { +// display: 'flex', +// alignItems: 'center' +// }, +// +// '.account-list': { +// '.__prop-label': { +// marginRight: token.marginMD, +// width: '50%', +// float: 'left' +// } +// }, +// +// '.network-box': { +// marginTop: token.margin +// }, +// +// '.to-account': { +// marginTop: token.margin - 2 +// }, +// +// '.__label': { +// textAlign: 'left' +// } +// })); +// +// export default BitcoinSendTransactionRequestConfirmation; diff --git a/packages/extension-koni-ui/src/Popup/Confirmations/variants/BitcoinSignPsbtConfirmation.tsx b/packages/extension-koni-ui/src/Popup/Confirmations/variants/BitcoinSignPsbtConfirmation.tsx new file mode 100644 index 00000000000..d39fba0a1b5 --- /dev/null +++ b/packages/extension-koni-ui/src/Popup/Confirmations/variants/BitcoinSignPsbtConfirmation.tsx @@ -0,0 +1,130 @@ +// Copyright 2019-2022 @subwallet/extension-koni-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// import { _ChainAsset } from '@subwallet/chain-list/types'; +// import { BitcoinSignPsbtRequest, ConfirmationsQueueItem, PsbtTransactionArg } from '@subwallet/extension-base/background/KoniTypes'; +// import { AccountItemWithName, ConfirmationGeneralInfo, MetaInfo, ViewDetailIcon } from '@subwallet/extension-koni-ui/components'; +// import { useOpenDetailModal } from '@subwallet/extension-koni-ui/hooks'; +// import { BitcoinSignArea } from '@subwallet/extension-koni-ui/Popup/Confirmations/parts'; +// import { RootState } from '@subwallet/extension-koni-ui/stores'; +// import { BitcoinSignatureSupportType, ThemeProps } from '@subwallet/extension-koni-ui/types'; +// import { findAccountByAddress } from '@subwallet/extension-koni-ui/utils'; +// import { Button, Number } from '@subwallet/react-ui'; +// import CN from 'classnames'; +// import React, { useCallback, useMemo } from 'react'; +// import { useTranslation } from 'react-i18next'; +// import { useSelector } from 'react-redux'; +// import styled from 'styled-components'; +// +// import { BaseDetailModal } from '../parts'; +// +// interface Props extends ThemeProps { +// type: BitcoinSignatureSupportType +// request: ConfirmationsQueueItem +// } +// +// function Component ({ className, request, type }: Props) { +// const { id, payload } = request; +// const { t } = useTranslation(); +// const { account } = payload; +// const { tokenSlug, txInput, txOutput } = request.payload.payload; +// const accounts = useSelector((state: RootState) => state.accountState.accounts); +// const assetRegistry = useSelector((root: RootState) => root.assetRegistry.assetRegistry); +// const onClickDetail = useOpenDetailModal(); +// const assetInfo: _ChainAsset | undefined = useMemo(() => { +// return assetRegistry[tokenSlug]; +// }, [assetRegistry, tokenSlug]); +// const renderAccount = useCallback((accountsPsbt: PsbtTransactionArg[]) => { +// return ( +//
+// { +// accountsPsbt.map(({ address, amount }) => { +// const account = findAccountByAddress(accounts, address); +// +// return ( +// : <>} +// />); +// } +// ) +// } +// +//
+// ); +// }, [accounts, assetInfo.decimals, assetInfo.symbol]); +// +// return ( +// <> +//
+// +//
+// {t('Signature required')} +//
+//
+// {t('You are approving a request with the following account')} +//
+// +//
+// +//
+//
+// +// +// +// +// {renderAccount(txInput)} +// +// +// {renderAccount(txOutput)} +// +// +// +// +// +// ); +// } +// +// const BitcoinSignPsbtConfirmation = styled(Component)(({ theme: { token } }: ThemeProps) => ({ +// '.account-list': { +// '.__prop-label': { +// marginRight: token.marginMD, +// width: '50%', +// float: 'left' +// } +// }, +// +// '.__label': { +// textAlign: 'left' +// } +// })); +// +// export default BitcoinSignPsbtConfirmation; diff --git a/packages/extension-koni-ui/src/Popup/Confirmations/variants/BitcoinSignatureConfirmation.tsx b/packages/extension-koni-ui/src/Popup/Confirmations/variants/BitcoinSignatureConfirmation.tsx new file mode 100644 index 00000000000..d78b7af97b6 --- /dev/null +++ b/packages/extension-koni-ui/src/Popup/Confirmations/variants/BitcoinSignatureConfirmation.tsx @@ -0,0 +1,89 @@ +// Copyright 2019-2022 @subwallet/extension-koni-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// import { BitcoinSignatureRequest, ConfirmationsQueueItem } from '@subwallet/extension-base/background/KoniTypes'; +// import { ConfirmationGeneralInfo, MetaInfo, ViewDetailIcon } from '@subwallet/extension-koni-ui/components'; +// import { useOpenDetailModal } from '@subwallet/extension-koni-ui/hooks'; +// import { BitcoinSignArea } from '@subwallet/extension-koni-ui/Popup/Confirmations/parts'; +// import { BitcoinSignatureSupportType, ThemeProps } from '@subwallet/extension-koni-ui/types'; +// import { Button } from '@subwallet/react-ui'; +// import CN from 'classnames'; +// import React from 'react'; +// import { useTranslation } from 'react-i18next'; +// import styled from 'styled-components'; +// +// import { BaseDetailModal } from '../parts'; +// +// interface Props extends ThemeProps { +// type: BitcoinSignatureSupportType +// request: ConfirmationsQueueItem +// } +// +// function Component ({ className, request, type }: Props) { +// const { id, payload } = request; +// const { t } = useTranslation(); +// // TODO: Temporarily comment out the AccountItemWithName component and recheck later. +// const { account } = payload; +// +// const onClickDetail = useOpenDetailModal(); +// +// return ( +// <> +//
+// +//
+// {t('Signature required')} +//
+//
+// {t('You are approving a request with the following account')} +//
+// {/* */} +//
+// +//
+//
+// +// +// +// {request.payload.payload as string} +// +// +// +// ); +// } +// +// const BitcoinSignatureConfirmation = styled(Component)(({ theme: { token } }: ThemeProps) => ({ +// '.account-list': { +// '.__prop-label': { +// marginRight: token.marginMD, +// width: '50%', +// float: 'left' +// } +// }, +// +// '.__label': { +// textAlign: 'left' +// } +// })); +// +// export default BitcoinSignatureConfirmation; diff --git a/packages/extension-koni-ui/src/Popup/Confirmations/variants/Transaction/index.tsx b/packages/extension-koni-ui/src/Popup/Confirmations/variants/Transaction/index.tsx index a761089bf34..a6790956f98 100644 --- a/packages/extension-koni-ui/src/Popup/Confirmations/variants/Transaction/index.tsx +++ b/packages/extension-koni-ui/src/Popup/Confirmations/variants/Transaction/index.tsx @@ -1,7 +1,7 @@ // Copyright 2019-2022 @subwallet/extension-koni-ui authors & contributors // SPDX-License-Identifier: Apache-2.0 -import { ConfirmationDefinitions, ConfirmationDefinitionsCardano, ConfirmationDefinitionsTon, ExtrinsicType } from '@subwallet/extension-base/background/KoniTypes'; +import { ConfirmationDefinitions, ConfirmationDefinitionsBitcoin, ConfirmationDefinitionsCardano, ConfirmationDefinitionsTon, ExtrinsicType } from '@subwallet/extension-base/background/KoniTypes'; import { SigningRequest } from '@subwallet/extension-base/background/types'; import { SWTransactionResult } from '@subwallet/extension-base/services/transaction-service/types'; import { ProcessType, SwapBaseTxData } from '@subwallet/extension-base/types'; @@ -19,7 +19,7 @@ import React, { useCallback, useMemo } from 'react'; import { useSelector } from 'react-redux'; import styled from 'styled-components'; -import { EvmSignArea, SubstrateSignArea } from '../../parts/Sign'; +import { BitcoinSignArea, EvmSignArea, SubstrateSignArea } from '../../parts/Sign'; import { BaseProcessConfirmation, BaseTransactionConfirmation, BondTransactionConfirmation, CancelUnstakeTransactionConfirmation, ClaimBridgeTransactionConfirmation, ClaimRewardTransactionConfirmation, DefaultWithdrawTransactionConfirmation, EarnProcessConfirmation, FastWithdrawTransactionConfirmation, JoinPoolTransactionConfirmation, JoinYieldPoolConfirmation, LeavePoolTransactionConfirmation, SendNftTransactionConfirmation, SwapProcessConfirmation, SwapTransactionConfirmation, TokenApproveConfirmation, TransferBlock, UnbondTransactionConfirmation, WithdrawTransactionConfirmation } from './variants'; interface Props extends ThemeProps { @@ -244,6 +244,16 @@ const Component: React.FC = (props: Props) => { /> ) } + { + (type === 'bitcoinSignatureRequest' || type === 'bitcoinSendTransactionRequest' || type === 'bitcoinWatchTransactionRequest' || type === 'bitcoinSignPsbtRequest') && ( + + ) + } ); }; diff --git a/packages/extension-koni-ui/src/Popup/Confirmations/variants/index.ts b/packages/extension-koni-ui/src/Popup/Confirmations/variants/index.ts index 0256cff1273..ad8a6375b08 100644 --- a/packages/extension-koni-ui/src/Popup/Confirmations/variants/index.ts +++ b/packages/extension-koni-ui/src/Popup/Confirmations/variants/index.ts @@ -12,6 +12,9 @@ export { default as SignConfirmation } from './SignConfirmation'; export { default as TransactionConfirmation } from './Transaction'; export { default as NotSupportWCConfirmation } from './NotSupportWCConfirmation'; export { default as CardanoSignTransactionConfirmation } from './CardanoSignTransactionConfirmation'; +// export { default as BitcoinSignatureConfirmation } from './BitcoinSignatureConfirmation'; +// export { default as BitcoinSignPsbtConfirmation } from './BitcoinSignPsbtConfirmation'; +// export { default as BitcoinSendTransactionRequestConfirmation } from './BitcoinSendTransactionRequestConfirmation'; export * from './Error'; export * from './Message'; diff --git a/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/RewardInfoPart.tsx b/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/RewardInfoPart.tsx index ceb8f769282..65fe4bfa9e9 100644 --- a/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/RewardInfoPart.tsx +++ b/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/RewardInfoPart.tsx @@ -8,7 +8,7 @@ import { EarningRewardHistoryItem, YieldPoolType, YieldPositionInfo } from '@sub import { isSameAddress } from '@subwallet/extension-base/utils'; import { CollapsiblePanel, MetaInfo } from '@subwallet/extension-koni-ui/components'; import { ASTAR_PORTAL_URL, BN_ZERO, CLAIM_REWARD_TRANSACTION, DEFAULT_CLAIM_REWARD_PARAMS, EarningStatusUi } from '@subwallet/extension-koni-ui/constants'; -import { useReformatAddress, useSelector, useTranslation, useYieldRewardTotal } from '@subwallet/extension-koni-ui/hooks'; +import { useCoreCreateReformatAddress, useSelector, useTranslation, useYieldRewardTotal } from '@subwallet/extension-koni-ui/hooks'; import { AlertDialogProps, ThemeProps } from '@subwallet/extension-koni-ui/types'; import { customFormatDate, openInNewTab } from '@subwallet/extension-koni-ui/utils'; import { ActivityIndicator, Button, Icon, Number } from '@subwallet/react-ui'; @@ -43,7 +43,7 @@ function Component ({ className, closeAlert, compound, inputAsset, isShowBalance const [, setClaimRewardStorage] = useLocalStorage(CLAIM_REWARD_TRANSACTION, DEFAULT_CLAIM_REWARD_PARAMS); const total = useYieldRewardTotal(slug); - const getReformatAddress = useReformatAddress(); + const getReformatAddress = useCoreCreateReformatAddress(); const isDAppStaking = useMemo(() => _STAKING_CHAIN_GROUP.astar.includes(compound.chain), [compound.chain]); const isMythosStaking = useMemo(() => _STAKING_CHAIN_GROUP.mythos.includes(compound.chain), [compound.chain]); diff --git a/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/index.tsx b/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/index.tsx index 005307b6569..db91af04148 100644 --- a/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/index.tsx +++ b/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/index.tsx @@ -3,6 +3,7 @@ import { NotificationType } from '@subwallet/extension-base/background/KoniTypes'; import { ALL_ACCOUNT_KEY } from '@subwallet/extension-base/constants'; +import { _isChainInfoCompatibleWithAccountInfo } from '@subwallet/extension-base/services/chain-service/utils'; import { EarningRewardHistoryItem, SpecialYieldPoolInfo, SpecialYieldPositionInfo, YieldPoolInfo, YieldPoolType, YieldPositionInfo } from '@subwallet/extension-base/types'; import { AlertModal, Layout, PageWrapper } from '@subwallet/extension-koni-ui/components'; import { BN_TEN, BN_ZERO, DEFAULT_EARN_PARAMS, DEFAULT_UN_STAKE_PARAMS, EARN_TRANSACTION, UN_STAKE_TRANSACTION } from '@subwallet/extension-koni-ui/constants'; @@ -13,7 +14,7 @@ import { EarningInfoPart } from '@subwallet/extension-koni-ui/Popup/Home/Earning import { RewardInfoPart } from '@subwallet/extension-koni-ui/Popup/Home/Earning/EarningPositionDetail/RewardInfoPart'; import { WithdrawInfoPart } from '@subwallet/extension-koni-ui/Popup/Home/Earning/EarningPositionDetail/WithdrawInfoPart'; import { EarningEntryParam, EarningEntryView, EarningPositionDetailParam, ThemeProps } from '@subwallet/extension-koni-ui/types'; -import { getTransactionFromAccountProxyValue, isAccountAll, isChainInfoAccordantAccountChainType } from '@subwallet/extension-koni-ui/utils'; +import { getTransactionFromAccountProxyValue, isAccountAll } from '@subwallet/extension-koni-ui/utils'; import { Button, ButtonProps, Icon, Number } from '@subwallet/react-ui'; import BigN from 'bignumber.js'; import CN from 'classnames'; @@ -52,11 +53,11 @@ function Component ({ compound, return ALL_ACCOUNT_KEY; } - const accountAddress = currentAccountProxy?.accounts.find(({ chainType }) => { + const accountAddress = currentAccountProxy?.accounts.find(({ chainType, type: accountType }) => { if (chainInfoMap[poolInfo.chain]) { const chainInfo = chainInfoMap[poolInfo.chain]; - return isChainInfoAccordantAccountChainType(chainInfo, chainType); + return _isChainInfoCompatibleWithAccountInfo(chainInfo, chainType, accountType); } return false; diff --git a/packages/extension-koni-ui/src/Popup/Home/History/Detail/parts/Layout.tsx b/packages/extension-koni-ui/src/Popup/Home/History/Detail/parts/Layout.tsx index 29214c7dc78..ecb1f650b40 100644 --- a/packages/extension-koni-ui/src/Popup/Home/History/Detail/parts/Layout.tsx +++ b/packages/extension-koni-ui/src/Popup/Home/History/Detail/parts/Layout.tsx @@ -56,7 +56,8 @@ const Component: React.FC = (props: Props) => { valueColorSchema={HistoryStatusMap[data.status].schema} /> {extrinsicHash} - {formatHistoryDate(data.time, language, 'detail')} + {!!data.time && ({formatHistoryDate(data.time, language, 'detail')})} + {!!data.blockTime && ({formatHistoryDate(data.blockTime, language, 'detail')})} { diff --git a/packages/extension-koni-ui/src/Popup/Home/History/index.tsx b/packages/extension-koni-ui/src/Popup/Home/History/index.tsx index f039e513896..2402c023cf5 100644 --- a/packages/extension-koni-ui/src/Popup/Home/History/index.tsx +++ b/packages/extension-koni-ui/src/Popup/Home/History/index.tsx @@ -70,7 +70,8 @@ function getIcon (item: TransactionHistoryItem): SwIconProps['phosphorIcon'] { function getDisplayData (item: TransactionHistoryItem, nameMap: Record, titleMap: Record): TransactionHistoryDisplayData { let displayData: TransactionHistoryDisplayData; - const time = customFormatDate(item.time, '#hhhh#:#mm#'); + const displayTime = item.blockTime || item.time; + const time = customFormatDate(displayTime, '#hhhh#:#mm#'); const displayStatus = item.status === ExtrinsicStatus.FAIL ? 'fail' : ''; @@ -177,6 +178,12 @@ function filterDuplicateItems (items: TransactionHistoryItem[]): TransactionHist return result; } +const PROCESSING_STATUSES: ExtrinsicStatus[] = [ + ExtrinsicStatus.QUEUED, + ExtrinsicStatus.SUBMITTING, + ExtrinsicStatus.PROCESSING +]; + const modalId = HISTORY_DETAIL_MODAL; const remindSeedPhraseModalId = REMIND_BACKUP_SEED_PHRASE_MODAL; const DEFAULT_ITEMS_COUNT = 20; @@ -374,8 +381,9 @@ function Component ({ className = '' }: Props): React.ReactElement { const fromName = accountMap[quickFormatAddressToCompare(item.from) || '']; const toName = accountMap[quickFormatAddressToCompare(item.to) || '']; const key = getHistoryItemKey(item); + const displayTime = item.blockTime || item.time; - finalHistoryMap[key] = { ...item, fromName, toName, displayData: getDisplayData(item, typeNameMap, typeTitleMap) }; + finalHistoryMap[key] = { ...item, fromName, toName, displayData: getDisplayData(item, typeNameMap, typeTitleMap), displayTime }; }); return finalHistoryMap; @@ -384,7 +392,19 @@ function Component ({ className = '' }: Props): React.ReactElement { const [currentItemDisplayCount, setCurrentItemDisplayCount] = useState(DEFAULT_ITEMS_COUNT); const getHistoryItems = useCallback((count: number) => { - return Object.values(historyMap).filter(filterFunction).sort((a, b) => (b.time - a.time)).slice(0, count); + return Object.values(historyMap).filter(filterFunction) + .sort((a, b) => { + if (PROCESSING_STATUSES.includes(a.status) && !PROCESSING_STATUSES.includes(b.status)) { + return -1; + } else if (PROCESSING_STATUSES.includes(b.status) && !PROCESSING_STATUSES.includes(a.status)) { + return 1; + } else if ((!!b.displayTime && !!a.displayTime) && (b.displayTime !== a.displayTime)) { + return b.displayTime - a.displayTime; + } else { + return (a.apiTxIndex ?? 0) - (b.apiTxIndex ?? 0); + } + }) + .slice(0, count); }, [filterFunction, historyMap]); const [historyItems, setHistoryItems] = useState(getHistoryItems(DEFAULT_ITEMS_COUNT)); @@ -482,9 +502,13 @@ function Component ({ className = '' }: Props): React.ReactElement { [onOpenDetail] ); - const groupBy = useCallback((item: TransactionHistoryItem) => { - return formatHistoryDate(item.time, language, 'list'); - }, [language]); + const groupBy = useCallback((item: TransactionHistoryDisplayItem) => { + if (PROCESSING_STATUSES.includes(item.status)) { + return t('Processing'); + } + + return formatHistoryDate(item.displayTime, language, 'list'); + }, [language, t]); const groupSeparator = useCallback((group: TransactionHistoryItem[], idx: number, groupLabel: string) => { return ( @@ -517,6 +541,7 @@ function Component ({ className = '' }: Props): React.ReactElement { { (isAllAccount || accountAddressItems.length > 1) && ( { let id: string; let isSubscribed = true; + if (!selectedChain) { + setLoading(false); + + return; + } + setLoading(true); setCurrentItemDisplayCount(DEFAULT_ITEMS_COUNT); diff --git a/packages/extension-koni-ui/src/Popup/Home/Tokens/DetailList.tsx b/packages/extension-koni-ui/src/Popup/Home/Tokens/DetailList.tsx index 5fd14d490b8..f9cf9b9b79e 100644 --- a/packages/extension-koni-ui/src/Popup/Home/Tokens/DetailList.tsx +++ b/packages/extension-koni-ui/src/Popup/Home/Tokens/DetailList.tsx @@ -12,7 +12,7 @@ import { TokenBalanceDetailItem } from '@subwallet/extension-koni-ui/components/ import { DEFAULT_SWAP_PARAMS, DEFAULT_TRANSFER_PARAMS, IS_SHOW_TON_CONTRACT_VERSION_WARNING, SWAP_TRANSACTION, TON_ACCOUNT_SELECTOR_MODAL, TON_WALLET_CONTRACT_SELECTOR_MODAL, TRANSFER_TRANSACTION } from '@subwallet/extension-koni-ui/constants'; import { DataContext } from '@subwallet/extension-koni-ui/contexts/DataContext'; import { HomeContext } from '@subwallet/extension-koni-ui/contexts/screen/HomeContext'; -import { useCoreReceiveModalHelper, useDefaultNavigate, useGetBannerByScreen, useGetChainSlugsByAccount, useNavigateOnChangeAccount, useNotification, useSelector } from '@subwallet/extension-koni-ui/hooks'; +import { useCoreReceiveModalHelper, useDefaultNavigate, useGetBannerByScreen, useGetChainSlugsByCurrentAccountProxy, useNavigateOnChangeAccount, useNotification, useSelector } from '@subwallet/extension-koni-ui/hooks'; import { canShowChart } from '@subwallet/extension-koni-ui/messaging'; import { DetailModal } from '@subwallet/extension-koni-ui/Popup/Home/Tokens/DetailModal'; import { DetailUpperBlock } from '@subwallet/extension-koni-ui/Popup/Home/Tokens/DetailUpperBlock'; @@ -76,7 +76,7 @@ function Component (): React.ReactElement { const [, setStorage] = useLocalStorage(TRANSFER_TRANSACTION, DEFAULT_TRANSFER_PARAMS); const [, setSwapStorage] = useLocalStorage(SWAP_TRANSACTION, DEFAULT_SWAP_PARAMS); const { banners, dismissBanner, onClickBanner } = useGetBannerByScreen('token_detail', tokenGroupSlug); - const allowedChains = useGetChainSlugsByAccount(); + const allowedChains = useGetChainSlugsByCurrentAccountProxy(); const isTonWalletContactSelectorModalActive = checkActive(tonWalletContractSelectorModalId); const [isShowTonWarning, setIsShowTonWarning] = useLocalStorage(IS_SHOW_TON_CONTRACT_VERSION_WARNING, true); const tonAddress = useMemo(() => { diff --git a/packages/extension-koni-ui/src/Popup/Home/Tokens/DetailModal.tsx b/packages/extension-koni-ui/src/Popup/Home/Tokens/DetailModal.tsx index f504352fabb..7489d3b6764 100644 --- a/packages/extension-koni-ui/src/Popup/Home/Tokens/DetailModal.tsx +++ b/packages/extension-koni-ui/src/Popup/Home/Tokens/DetailModal.tsx @@ -2,13 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 import { APIItemState } from '@subwallet/extension-base/background/KoniTypes'; -import { BalanceItem } from '@subwallet/extension-base/types'; +import { _isChainBitcoinCompatible } from '@subwallet/extension-base/services/chain-service/utils'; import { AccountTokenBalanceItem, EmptyList, RadioGroup } from '@subwallet/extension-koni-ui/components'; import { useSelector } from '@subwallet/extension-koni-ui/hooks'; import useTranslation from '@subwallet/extension-koni-ui/hooks/common/useTranslation'; -import { ThemeProps } from '@subwallet/extension-koni-ui/types'; +import { RootState } from '@subwallet/extension-koni-ui/stores'; +import { BalanceItemWithAddressType, ThemeProps } from '@subwallet/extension-koni-ui/types'; import { TokenBalanceItemType } from '@subwallet/extension-koni-ui/types/balance'; -import { isAccountAll } from '@subwallet/extension-koni-ui/utils'; +import { getBitcoinAccountDetails, getBitcoinKeypairAttributes, isAccountAll } from '@subwallet/extension-koni-ui/utils'; +import { getKeypairTypeByAddress, isBitcoinAddress } from '@subwallet/keyring'; import { Form, Icon, ModalContext, Number, SwModal } from '@subwallet/react-ui'; import BigN from 'bignumber.js'; import CN from 'classnames'; @@ -57,14 +59,39 @@ function Component ({ className = '', currentTokenInfo, id, onCancel, tokenBalan const { accounts, currentAccountProxy, isAllAccount } = useSelector((state) => state.accountState); const { balanceMap } = useSelector((state) => state.balance); - + const chainInfoMap = useSelector((state: RootState) => state.chainStore.chainInfoMap); const [form] = Form.useForm(); + const viewValue = Form.useWatch('view', form); + + const balanceInfo = useMemo( + () => (currentTokenInfo ? tokenBalanceMap[currentTokenInfo.slug] : undefined), + [currentTokenInfo, tokenBalanceMap] + ); + + const chainInfo = useMemo( + () => (balanceInfo?.chain ? chainInfoMap[balanceInfo.chain] : undefined), + [balanceInfo, chainInfoMap] + ); - const view = Form.useWatch('view', form); + const isBitcoinChain = useMemo(() => { + if (!chainInfo) { + return false; + } + + return _isChainBitcoinCompatible(chainInfo); + }, [chainInfo]); + + const currentView = useMemo(() => { + if (isBitcoinChain) { + return ViewValue.DETAIL; + } else { + return viewValue; + } + }, [isBitcoinChain, viewValue]); const defaultValues = useMemo((): FormState => ({ - view: ViewValue.OVERVIEW - }), []); + view: isBitcoinChain ? ViewValue.DETAIL : ViewValue.OVERVIEW + }), [isBitcoinChain]); const viewOptions = useMemo((): ViewOption[] => { return [ @@ -79,69 +106,96 @@ function Component ({ className = '', currentTokenInfo, id, onCancel, tokenBalan ]; }, [t]); - const items = useMemo((): ItemType[] => { + const overviewItems = useMemo((): ItemType[] => { const symbol = currentTokenInfo?.symbol || ''; - const balanceInfo = currentTokenInfo ? tokenBalanceMap[currentTokenInfo.slug] : undefined; - - const result: ItemType[] = []; - - result.push({ - key: 'transferable', + const createItem = (key: string, label: string, value: BigN): ItemType => ({ + key, symbol, - label: t('Transferable'), - value: balanceInfo ? balanceInfo.free.value : new BigN(0) + label, + value }); - result.push({ - key: 'locked', - symbol, - label: t('Locked'), - value: balanceInfo ? balanceInfo.locked.value : new BigN(0) - }); + const transferableValue = balanceInfo?.free.value ?? new BigN(0); + const lockedValue = balanceInfo?.locked.value ?? new BigN(0); - return result; - }, [currentTokenInfo, t, tokenBalanceMap]); + return [ + createItem('transferable', t('Transferable'), transferableValue), + createItem('locked', t('Locked'), lockedValue) + ]; + }, [balanceInfo?.free.value, balanceInfo?.locked.value, currentTokenInfo?.symbol, t]); - const accountItems = useMemo((): BalanceItem[] => { + const detailItems = useMemo((): BalanceItemWithAddressType[] => { if (!currentAccountProxy || !currentTokenInfo?.slug) { return []; } - const result: BalanceItem[] = []; + const result: BalanceItemWithAddressType[] = []; + + for (const [accountId, info] of Object.entries(balanceMap)) { + // Check if account is valid + const isValidAccount = isAllAccount + ? !isAccountAll(accountId) && accounts.some((a) => a.address === accountId) + : currentAccountProxy.accounts.some((a) => a.address === accountId); - const filterAccountId = (accountId: string) => { - if (isAllAccount) { - return !isAccountAll(accountId) && accounts.some((a) => a.address === accountId); - } else { - return currentAccountProxy.accounts.some((a) => a.address === accountId); + if (!isValidAccount) { + continue; } - }; - for (const [accountId, info] of Object.entries(balanceMap)) { - if (filterAccountId(accountId)) { - const item = info[currentTokenInfo.slug]; + const item = info[currentTokenInfo.slug]; - if (item && item.state === APIItemState.READY) { - result.push(item); - } + if (!item || item.state !== APIItemState.READY) { + continue; + } + + const totalBalance = new BigN(item.free).plus(BigN(item.locked)); + + // Check if balance is greater than 0 + if (totalBalance.lte(0) && (!isBitcoinChain || isAllAccount)) { + continue; + } + + // Extend item with addressTypeLabel if needed + const resultItem: BalanceItemWithAddressType = { ...item }; + + if (isBitcoinAddress(item.address)) { + const keyPairType = getKeypairTypeByAddress(item.address); + + const attributes = getBitcoinKeypairAttributes(keyPairType); + + resultItem.addressTypeLabel = attributes.label; + resultItem.schema = attributes.schema; } + + result.push(resultItem); } - return result.sort((a, b) => { - const aTotal = new BigN(a.free).plus(BigN(a.locked)); - const bTotal = new BigN(b.free).plus(BigN(b.locked)); + // Sort by total balance in descending order + return result + .sort((a, b) => { + const _isABitcoin = isBitcoinAddress(a.address); + const _isBBitcoin = isBitcoinAddress(b.address); - return bTotal.minus(aTotal).toNumber(); - }); - }, [accounts, balanceMap, currentAccountProxy, currentTokenInfo?.slug, isAllAccount]); + if (_isABitcoin && _isBBitcoin) { + const aKeyPairType = getKeypairTypeByAddress(a.address); + const bKeyPairType = getKeypairTypeByAddress(b.address); - const symbol = currentTokenInfo?.symbol || ''; + const aDetails = getBitcoinAccountDetails(aKeyPairType); + const bDetails = getBitcoinAccountDetails(bKeyPairType); - const filteredItems = useMemo(() => { - return accountItems.filter((item) => { - return new BigN(item.free).plus(item.locked).gt(0); - }); - }, [accountItems]); + return aDetails.order - bDetails.order; + } + + return 0; + }) + .sort((a, b) => { + const aTotal = new BigN(a.free).plus(BigN(a.locked)); + const bTotal = new BigN(b.free).plus(BigN(b.locked)); + + return bTotal.minus(aTotal).toNumber(); + }); + }, [accounts, balanceMap, currentAccountProxy, currentTokenInfo?.slug, isAllAccount, isBitcoinChain]); + + const symbol = currentTokenInfo?.symbol || ''; useEffect(() => { if (!isActive) { @@ -154,7 +208,7 @@ function Component ({ className = '', currentTokenInfo, id, onCancel, tokenBalan className={CN(className, { 'fix-height': isAllAccount })} id={id} onCancel={onCancel} - title={t('Token details')} + title={(isAllAccount && isBitcoinChain) ? t('Account Details') : t('Token details')} >