From 8385aeacd378868c78830c2a4375d4a6d5f704f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Fri, 2 Aug 2024 20:47:12 +0200 Subject: [PATCH] refactor: migrate bridge logic to frontend (#4) --- package.json | 9 - .../connections/extensionConnection/models.ts | 1 + .../extensionConnection/registry.ts | 26 +- .../balances/handlers/getNftBalances.ts | 6 + .../services/blockaid/BlockaidService.ts | 6 +- .../services/bridge/BridgeService.test.ts | 275 ----- .../services/bridge/BridgeService.ts | 211 ---- .../handlers/avalanche_bridgeAsset.test.ts | 118 +- .../bridge/handlers/avalanche_bridgeAsset.ts | 193 ++- .../handlers/getEthMaxTransferAmount.test.ts | 270 ---- .../handlers/getEthMaxTransferAmount.ts | 97 -- .../services/bridge/handlers/transferAsset.ts | 78 -- .../debank/utils/txParamsToTransactionData.ts | 3 +- .../UnifiedBridgeService.test.ts | 367 +----- .../unifiedBridge/UnifiedBridgeService.ts | 246 +--- .../unifiedBridge/events/eventFilters.ts | 6 - .../services/unifiedBridge/handlers/index.ts | 2 - .../handlers/unifiedBridgeEstimateGas.test.ts | 51 - .../handlers/unifiedBridgeEstimateGas.ts | 37 - .../handlers/unifiedBridgeGetFee.test.ts | 47 - .../handlers/unifiedBridgeGetFee.ts | 41 - ...ssets.ts => unifiedBridgeTrackTransfer.ts} | 19 +- .../unifiedBridgeTransferAsset.test.ts | 50 - .../handlers/unifiedBridgeTransferAsset.ts | 52 - .../services/unifiedBridge/models.ts | 3 +- .../handlers/bitcoin_sendTransaction.ts | 26 +- .../eth_sendTransaction.ts | 27 +- .../handlers/eth_sendTransaction/models.ts | 4 +- .../eth_sendTransaction/utils/getTxInfo.ts | 9 +- .../services/wallet/handlers/models.ts | 9 + src/components/common/BNInput.tsx | 6 +- src/contexts/BridgeProvider.test.tsx | 343 ++++++ src/contexts/BridgeProvider.tsx | 197 ++- src/contexts/NetworkFeeProvider.tsx | 2 +- src/contexts/NetworkProvider.tsx | 29 +- src/contexts/SettingsProvider.tsx | 5 +- src/contexts/UnifiedBridgeProvider.test.tsx | 400 ++++++ src/contexts/UnifiedBridgeProvider.tsx | 408 ++++-- src/hooks/useErrorMessage.ts | 3 + src/localization/locales/en/translation.json | 13 +- src/pages/ApproveAction/BitcoinSignTx.tsx | 5 +- src/pages/Bridge/Bridge.tsx | 1097 +++-------------- src/pages/Bridge/BridgeConfirmation.tsx | 278 ----- src/pages/Bridge/components/BridgeForm.tsx | 706 +++++++++++ .../Bridge/components/BridgeFormAVAX.tsx | 53 + src/pages/Bridge/components/BridgeFormBTC.tsx | 50 + src/pages/Bridge/components/BridgeFormETH.tsx | 49 + .../Bridge/components/BridgeFormUnified.tsx | 53 + .../components/BridgeUnknownNetwork.tsx | 2 +- .../Bridge/components/NetworkSelector.tsx | 112 +- .../Bridge/hooks/useAvalancheBridge.test.ts | 213 ++++ src/pages/Bridge/hooks/useAvalancheBridge.ts | 78 +- src/pages/Bridge/hooks/useBridge.ts | 107 +- src/pages/Bridge/hooks/useBridgeTxHandling.ts | 47 + src/pages/Bridge/hooks/useBtcBridge.test.ts | 245 ++++ src/pages/Bridge/hooks/useBtcBridge.ts | 288 ++--- src/pages/Bridge/hooks/useEthBridge.test.ts | 228 ++++ src/pages/Bridge/hooks/useEthBridge.ts | 157 +-- src/pages/Bridge/hooks/useUnifiedBridge.ts | 92 +- src/pages/SignTransaction/SignTransaction.tsx | 15 + .../hooks/useSignTransactionHeader.ts | 4 + .../InProgressBridgeActivityCard.tsx | 12 +- src/utils/handleTxOutcome.ts | 2 +- src/utils/updateIfDifferent.ts | 33 + src/utils/useWindowGetsClosedOrHidden.ts | 17 +- 65 files changed, 3819 insertions(+), 3819 deletions(-) delete mode 100644 src/background/services/bridge/handlers/getEthMaxTransferAmount.test.ts delete mode 100644 src/background/services/bridge/handlers/getEthMaxTransferAmount.ts delete mode 100644 src/background/services/bridge/handlers/transferAsset.ts delete mode 100644 src/background/services/unifiedBridge/handlers/unifiedBridgeEstimateGas.test.ts delete mode 100644 src/background/services/unifiedBridge/handlers/unifiedBridgeEstimateGas.ts delete mode 100644 src/background/services/unifiedBridge/handlers/unifiedBridgeGetFee.test.ts delete mode 100644 src/background/services/unifiedBridge/handlers/unifiedBridgeGetFee.ts rename src/background/services/unifiedBridge/handlers/{unifiedBridgeGetAssets.ts => unifiedBridgeTrackTransfer.ts} (56%) delete mode 100644 src/background/services/unifiedBridge/handlers/unifiedBridgeTransferAsset.test.ts delete mode 100644 src/background/services/unifiedBridge/handlers/unifiedBridgeTransferAsset.ts create mode 100644 src/contexts/BridgeProvider.test.tsx create mode 100644 src/contexts/UnifiedBridgeProvider.test.tsx delete mode 100644 src/pages/Bridge/BridgeConfirmation.tsx create mode 100644 src/pages/Bridge/components/BridgeForm.tsx create mode 100644 src/pages/Bridge/components/BridgeFormAVAX.tsx create mode 100644 src/pages/Bridge/components/BridgeFormBTC.tsx create mode 100644 src/pages/Bridge/components/BridgeFormETH.tsx create mode 100644 src/pages/Bridge/components/BridgeFormUnified.tsx create mode 100644 src/pages/Bridge/hooks/useAvalancheBridge.test.ts create mode 100644 src/pages/Bridge/hooks/useBridgeTxHandling.ts create mode 100644 src/pages/Bridge/hooks/useBtcBridge.test.ts create mode 100644 src/pages/Bridge/hooks/useEthBridge.test.ts create mode 100644 src/utils/updateIfDifferent.ts diff --git a/package.json b/package.json index 0a931bfe1..901b9d98a 100644 --- a/package.json +++ b/package.json @@ -213,15 +213,7 @@ }, "lavamoat": { "allowScripts": { - "@avalabs/bridge-sdk>@avalabs/wallets-sdk>@ledgerhq/hw-app-btc>bitcoinjs-lib>bip32>tiny-secp256k1": false, "@ethereumjs/common>ethereumjs-util>ethereum-cryptography>keccak": false, - "@avalabs/bridge-sdk>@avalabs/wallets-sdk>web3": false, - "@avalabs/bridge-sdk>@avalabs/wallets-sdk>web3>web3-bzz": false, - "@avalabs/bridge-sdk>@avalabs/wallets-sdk>web3>web3-core>web3-core-requestmanager>web3-providers-ws>websocket>bufferutil": false, - "@avalabs/bridge-sdk>@avalabs/wallets-sdk>web3>web3-core>web3-core-requestmanager>web3-providers-ws>websocket>es5-ext": false, - "@avalabs/bridge-sdk>@avalabs/wallets-sdk>web3>web3-core>web3-core-requestmanager>web3-providers-ws>websocket>utf-8-validate": false, - "@avalabs/bridge-sdk>@avalabs/wallets-sdk>web3>web3-shh": false, - "@avalabs/bridge-sdk>@avalabs/wallets-sdk>hdkey>secp256k1": false, "@lavamoat/preinstall-always-fail": false, "@types/web3>web3": false, "@types/web3>web3>web3-bzz": false, @@ -240,7 +232,6 @@ "web3>web3-bzz": false, "web3>web3-shh": false, "yarn": false, - "@avalabs/bridge-sdk>@avalabs/wallets-sdk>@avalabs/hw-app-avalanche>@ledgerhq/hw-app-eth>@ledgerhq/domain-service>eip55>keccak": false, "@avalabs/vm-module-types": false, "@avalabs/vm-module-types>@avalabs/wallets-sdk>@avalabs/hw-app-avalanche>@ledgerhq/hw-app-eth>@ledgerhq/domain-service>eip55>keccak": false, "@avalabs/vm-module-types>@avalabs/wallets-sdk>@ledgerhq/hw-app-btc>bitcoinjs-lib>bip32>tiny-secp256k1": false, diff --git a/src/background/connections/extensionConnection/models.ts b/src/background/connections/extensionConnection/models.ts index bf1c38279..573722978 100644 --- a/src/background/connections/extensionConnection/models.ts +++ b/src/background/connections/extensionConnection/models.ts @@ -149,6 +149,7 @@ export enum ExtensionRequest { UNIFIED_BRIDGE_GET_FEE = 'unified_bridge_get_fee', UNIFIED_BRIDGE_ESTIMATE_GAS = 'unified_bridge_estimate_gas', UNIFIED_BRIDGE_TRANSFER_ASSET = 'unified_bridge_transfer_asset', + UNIFIED_BRIDGE_TRACK_TRANSFER = 'unified_bridge_track_transfer', UNIFIED_BRIDGE_GET_STATE = 'unified_bridge_get_state', UNIFIED_BRIDGE_GET_ASSETS = 'unified_bridge_get_assets', diff --git a/src/background/connections/extensionConnection/registry.ts b/src/background/connections/extensionConnection/registry.ts index 7d19ff350..835d5dd11 100644 --- a/src/background/connections/extensionConnection/registry.ts +++ b/src/background/connections/extensionConnection/registry.ts @@ -21,7 +21,6 @@ import { BridgeGetConfigHandler } from '@src/background/services/bridge/handlers import { BridgeGetStateHandler } from '@src/background/services/bridge/handlers/getBridgeState'; import { BridgeRemoveTransactionHandler } from '@src/background/services/bridge/handlers/removeBridgeTransaction'; import { BridgeSetIsDevEnvHandler } from '@src/background/services/bridge/handlers/setIsDevEnv'; -import { BridgeTransferAssetHandler } from '@src/background/services/bridge/handlers/transferAsset'; import { ContactsUpdatedEvents } from '@src/background/services/contacts/events/contactsUpdatedEvent'; import { CreateContactHandler } from '@src/background/services/contacts/handlers/createContact'; import { GetContactsHandler } from '@src/background/services/contacts/handlers/getContacts'; @@ -75,7 +74,6 @@ import { RemoveFavoriteNetworkHandler } from '@src/background/services/network/h import { GetNetworksStateHandler } from '@src/background/services/network/handlers/getNetworkState'; import { GetFeatureFlagsHandler } from '@src/background/services/featureFlags/handlers/getFeatureFlags'; import { FeatureFlagsUpdatedEvent } from '@src/background/services/featureFlags/events/featureFlagsUpdatedEvent'; -import { GetEthMaxTransferAmountHandler } from '@src/background/services/bridge/handlers/getEthMaxTransferAmount'; import { CloseLedgerTransportHandler } from '@src/background/services/ledger/handlers/closeOpenTransporters'; import { LedgerCloseTransportEvent } from '@src/background/services/ledger/events/ledgerCloseTransport'; import { GetAvaxBalanceHandler } from '@src/background/services/balances/handlers/getAvaxBalance'; @@ -107,14 +105,10 @@ import { GetRecoveryPhraseExportStateHandler } from '@src/background/services/se import { InitRecoveryPhraseExportHandler } from '@src/background/services/seedless/handlers/initRecoveryPhraseExport'; import { CompleteRecoveryPhraseExportHandler } from '@src/background/services/seedless/handlers/completeRecoveryPhraseExport'; import { CancelRecoveryPhraseExportHandler } from '@src/background/services/seedless/handlers/cancelRecoveryPhraseExport'; -import { UnifiedBridgeTransferAsset } from '@src/background/services/unifiedBridge/handlers/unifiedBridgeTransferAsset'; -import { UnifiedBridgeGetFee } from '@src/background/services/unifiedBridge/handlers/unifiedBridgeGetFee'; import { UnifiedBridgeGetState } from '@src/background/services/unifiedBridge/handlers/unifiedBridgeGetState'; -import { UnifiedBridgeGetAssets } from '@src/background/services/unifiedBridge/handlers/unifiedBridgeGetAssets'; import { UnifiedBridgeEvents } from '@src/background/services/unifiedBridge/events/unifiedBridgeEvents'; import { GetPrivateKeyHandler } from '@src/background/services/accounts/handlers/getPrivateKey'; import { EstimateGasForBridgeTxHandler } from '@src/background/services/bridge/handlers/estimateGasForBridgeTx'; -import { UnifiedBridgeEstimateGas } from '@src/background/services/unifiedBridge/handlers/unifiedBridgeEstimateGas'; import { ImportSeedPhraseHandler } from '@src/background/services/wallet/handlers/importSeedPhrase'; import { ImportLedgerHandler } from '@src/background/services/wallet/handlers/importLedger'; import { GetRecoveryMethodsHandler } from '@src/background/services/seedless/handlers/getRecoveryMethods'; @@ -134,6 +128,7 @@ import { SetActiveNetworkHandler } from '@src/background/services/network/handle import { StartBalancesPollingHandler } from '@src/background/services/balances/handlers/startBalancesPolling'; import { StopBalancesPollingHandler } from '@src/background/services/balances/handlers/stopBalancesPolling'; import { BalancesUpdatedEvents } from '@src/background/services/balances/events/balancesUpdatedEvent'; +import { UnifiedBridgeTrackTransfer } from '@src/background/services/unifiedBridge/handlers/unifiedBridgeTrackTransfer'; /** * TODO: GENERATE THIS FILE AS PART OF THE BUILD PROCESS @@ -171,11 +166,6 @@ import { BalancesUpdatedEvents } from '@src/background/services/balances/events/ token: 'ExtensionRequestHandler', useToken: BridgeRemoveTransactionHandler, }, - { token: 'ExtensionRequestHandler', useToken: BridgeTransferAssetHandler }, - { - token: 'ExtensionRequestHandler', - useToken: GetEthMaxTransferAmountHandler, - }, { token: 'ExtensionRequestHandler', useToken: CreateContactHandler }, { token: 'ExtensionRequestHandler', useToken: GetContactsHandler }, { token: 'ExtensionRequestHandler', useToken: UpdateContactHandler }, @@ -318,20 +308,12 @@ import { BalancesUpdatedEvents } from '@src/background/services/balances/events/ }, { token: 'ExtensionRequestHandler', - useToken: UnifiedBridgeTransferAsset, - }, - { - token: 'ExtensionRequestHandler', - useToken: UnifiedBridgeGetFee, + useToken: UnifiedBridgeTrackTransfer, }, { token: 'ExtensionRequestHandler', useToken: UnifiedBridgeGetState, }, - { - token: 'ExtensionRequestHandler', - useToken: UnifiedBridgeGetAssets, - }, { token: 'ExtensionRequestHandler', useToken: GetPrivateKeyHandler, @@ -340,10 +322,6 @@ import { BalancesUpdatedEvents } from '@src/background/services/balances/events/ token: 'ExtensionRequestHandler', useToken: EstimateGasForBridgeTxHandler, }, - { - token: 'ExtensionRequestHandler', - useToken: UnifiedBridgeEstimateGas, - }, { token: 'ExtensionRequestHandler', useToken: ImportSeedPhraseHandler, diff --git a/src/background/services/balances/handlers/getNftBalances.ts b/src/background/services/balances/handlers/getNftBalances.ts index 90a190116..76c1c3c89 100644 --- a/src/background/services/balances/handlers/getNftBalances.ts +++ b/src/background/services/balances/handlers/getNftBalances.ts @@ -32,6 +32,12 @@ export class GetNftBalancesHandler implements HandlerType { [TokenType.ERC1155]: undefined, }; } + if (!scope) { + return { + ...request, + error: 'No request scope provided', + }; + } const currentNetwork = await this.networkService.getNetwork(scope); if (!currentNetwork) { diff --git a/src/background/services/blockaid/BlockaidService.ts b/src/background/services/blockaid/BlockaidService.ts index 7fea4911c..ac3a15c82 100644 --- a/src/background/services/blockaid/BlockaidService.ts +++ b/src/background/services/blockaid/BlockaidService.ts @@ -137,7 +137,11 @@ export class BlockaidService { from: tx.from, to: tx.to, data: tx.data, - value: tx.value, + // BigInt cannot be JSON-stringified + value: + typeof tx.value === 'bigint' + ? `0x${tx.value.toString(16)}` + : tx.value, }, metadata: { domain }, }); diff --git a/src/background/services/bridge/BridgeService.test.ts b/src/background/services/bridge/BridgeService.test.ts index 04b623e4a..302cb528a 100644 --- a/src/background/services/bridge/BridgeService.test.ts +++ b/src/background/services/bridge/BridgeService.test.ts @@ -4,7 +4,6 @@ import { getBtcTransactionDetails, } from '@avalabs/core-bridge-sdk'; import { BITCOIN_NETWORK, ChainId } from '@avalabs/core-chains-sdk'; -import { BitcoinProvider } from '@avalabs/core-wallets-sdk'; import Big from 'big.js'; import { AccountsService } from '../accounts/AccountsService'; @@ -12,15 +11,9 @@ import { AccountType } from '../accounts/models'; import { BalanceAggregatorService } from '../balances/BalanceAggregatorService'; import { FeatureFlagService } from '../featureFlags/FeatureFlagService'; import { NetworkService } from '../network/NetworkService'; -import { NetworkFeeService } from '../networkFee/NetworkFeeService'; import { StorageService } from '../storage/StorageService'; -import { WalletService } from '../wallet/WalletService'; import { BridgeService } from './BridgeService'; -import { CommonError } from '@src/utils/errors'; -import { FireblocksErrorCode } from '../fireblocks/models'; -import { ethErrors } from 'eth-rpc-errors'; -import { getProviderForNetwork } from '@src/utils/network/getProviderForNetwork'; jest.mock('@avalabs/core-bridge-sdk', () => { const { mockConfig } = require('./fixtures/mockBridgeConfig'); @@ -53,18 +46,10 @@ const networkService = jest.mocked({ getEthereumProvider: jest.fn(), } as any); -const walletService = jest.mocked({ - sign: jest.fn(), -} as any); - const featureFlagService = { ensureFlagEnabled: jest.fn(), } as unknown as FeatureFlagService; -const networkFeeService = jest.mocked({ - getNetworkFee: jest.fn(), -} as any); - const addressBTC = 'tb01234'; const networkBalancesService = { @@ -77,265 +62,7 @@ const networkBalancesService = { }), } as unknown as BalanceAggregatorService; -const utxos = [{ index: 1 }, { index: 2 }]; -const utxosWithScript = [ - { index: 1, script: 'script1' }, - { index: 2, script: 'script2' }, -]; - describe('src/background/services/bridge/BridgeService.ts', () => { - describe('when active account is a WalletConnect account', () => { - const accountsService = { - activeAccount: { - addressC: '1234-abcd', - type: AccountType.WALLET_CONNECT, - }, - } as unknown as AccountsService; - - let service: BridgeService; - - beforeEach(async () => { - service = new BridgeService( - storageService, - networkService, - walletService, - accountsService, - featureFlagService, - networkFeeService, - networkBalancesService - ); - await service.onStorageReady(); - }); - - it('does not allow bridging BTC', async () => { - await expect( - service.transferBtcAsset(new Big(1000), undefined, 1234) - ).rejects.toThrow( - 'WalletConnect accounts are not supported by Bridge yet' - ); - }); - }); - - describe('when transaction signing fails', () => { - const provider = { - getScriptsForUtxos: jest.fn().mockResolvedValue([]), - } as unknown as BitcoinProvider; - - const accountsService = { - activeAccount: { - addressC: '1234-abcd', - addressBTC, - type: AccountType.PRIMARY, - }, - } as unknown as AccountsService; - - let service: BridgeService; - - beforeEach(async () => { - jest.mocked(getBtcTransactionDetails).mockReturnValue({ - inputs: [], - outputs: [], - } as any); - - service = new BridgeService( - storageService, - networkService, - walletService, - accountsService, - featureFlagService, - networkFeeService, - networkBalancesService - ); - - jest.mocked(getProviderForNetwork).mockReturnValue(provider); - - await service.onStorageReady(); - }); - - it('propagates recognized errors', async () => { - walletService.sign.mockRejectedValueOnce( - ethErrors.rpc.transactionRejected({ - data: { reason: FireblocksErrorCode.Blocked }, - }) - ); - - await expect( - service.transferBtcAsset(new Big(1000), undefined, 1234) - ).rejects.toThrow( - ethErrors.rpc.transactionRejected({ - data: { reason: FireblocksErrorCode.Blocked }, - }) - ); - }); - - it('defaults to unknown error if original exception is not recognized', async () => { - walletService.sign.mockRejectedValueOnce(new Error('what is dis')); - - await expect( - service.transferBtcAsset(new Big(1000), undefined, 1234) - ).rejects.toThrow( - ethErrors.rpc.internal({ data: { reason: CommonError.Unknown } }) - ); - }); - }); - - describe('when a signed tx is received from WalletService', () => { - const accountsService = { - activeAccount: { - addressC: '1234-abcd', - addressBTC, - type: AccountType.FIREBLOCKS, - }, - } as unknown as AccountsService; - - const maxFee = 123n; - const txIssueResult = { - hash: '0xHASH', - fees: 123, - confirmations: 1, - }; - - const provider = { - issueRawTx: jest.fn().mockResolvedValue(txIssueResult.hash), - getScriptsForUtxos: jest.fn().mockResolvedValue(utxosWithScript), - waitForTx: jest.fn().mockResolvedValue(txIssueResult), - } as unknown as BitcoinProvider; - - const signedTx = '0x1234567890'; - - let service: BridgeService; - - beforeEach(async () => { - service = new BridgeService( - storageService, - networkService, - walletService, - accountsService, - featureFlagService, - networkFeeService, - networkBalancesService - ); - - await service.onStorageReady(); - - networkFeeService.getNetworkFee.mockResolvedValue({ - high: { maxFee }, - } as any); - - jest.mocked(getProviderForNetwork).mockReturnValue(provider); - - jest.mocked(getBtcTransactionDetails).mockReturnValue({ - inputs: utxos, - outputs: [], - } as any); - - walletService.sign.mockResolvedValue({ signedTx }); - }); - - it('issues the transaction and returns its details', async () => { - const { hash, confirmations, from, gasLimit, value } = - await service.transferBtcAsset(new Big(0.0001), undefined, 1234); - - expect(provider.getScriptsForUtxos).toHaveBeenCalledWith(utxos); - expect(provider.issueRawTx).toHaveBeenCalledWith(signedTx); - expect(provider.waitForTx).toHaveBeenCalledWith(txIssueResult.hash); - - expect(walletService.sign).toHaveBeenCalledWith( - { - inputs: utxosWithScript, - outputs: [], - }, - BITCOIN_NETWORK, - 1234 - ); - - expect({ hash, confirmations, from }).toStrictEqual({ - hash: txIssueResult.hash, - confirmations: txIssueResult.confirmations, - from: addressBTC, - }); - - // We need to compare gasLimit & value outside of the object - // and as regular numbers, otherwise Jest is trying to serialize - // the BigInts and fails. - // Reference: https://github.com/jestjs/jest/issues/11617 - expect(Number(gasLimit)).toEqual(txIssueResult.fees); - expect(Number(value)).toEqual(10000); - }); - }); - - describe('when a tx hash is received from WalletService', () => { - const accountsService = { - activeAccount: { - addressC: '1234-abcd', - addressBTC, - type: AccountType.FIREBLOCKS, - }, - } as unknown as AccountsService; - - const maxFee = 123n; - const txLookupResult = { - hash: '0xHASH', - fees: 123, - confirmations: 1, - }; - - const provider = { - getScriptsForUtxos: jest.fn().mockResolvedValue(utxosWithScript), - waitForTx: jest.fn().mockResolvedValue(txLookupResult), - } as unknown as BitcoinProvider; - - const txHash = '0x1234567890'; - - let service: BridgeService; - - beforeEach(async () => { - service = new BridgeService( - storageService, - networkService, - walletService, - accountsService, - featureFlagService, - networkFeeService, - networkBalancesService - ); - await service.onStorageReady(); - - networkFeeService.getNetworkFee.mockResolvedValue({ - high: { maxFee }, - } as any); - - jest.mocked(getProviderForNetwork).mockReturnValue(provider); - - jest.mocked(getBtcTransactionDetails).mockReturnValue({ - inputs: [], - outputs: [], - } as any); - - walletService.sign.mockResolvedValue({ txHash }); - }); - - it('looks up the transaction details on the blockchain and returns them', async () => { - const { hash, confirmations, from, gasLimit, value } = - await service.transferBtcAsset(new Big(0.0001), undefined, 1234); - - expect(provider.waitForTx).toHaveBeenCalledWith(txHash); - - expect({ hash, confirmations, from }).toStrictEqual({ - hash: txLookupResult.hash, - confirmations: txLookupResult.confirmations, - from: addressBTC, - }); - - // We need to compare gasLimit & value outside of the object - // and as regular numbers, otherwise Jest is trying to serialize - // the BigInts and fails. - // Reference: https://github.com/jestjs/jest/issues/11617 - expect(Number(gasLimit)).toEqual(txLookupResult.fees); - expect(Number(value)).toEqual(10000); - }); - }); - describe('.estimateGas()', () => { const accountsService = { activeAccount: { @@ -351,10 +78,8 @@ describe('src/background/services/bridge/BridgeService.ts', () => { service = new BridgeService( storageService, networkService, - walletService, accountsService, featureFlagService, - networkFeeService, networkBalancesService ); await service.onStorageReady(); diff --git a/src/background/services/bridge/BridgeService.ts b/src/background/services/bridge/BridgeService.ts index 5ede29d0b..36e6b6fc2 100644 --- a/src/background/services/bridge/BridgeService.ts +++ b/src/background/services/bridge/BridgeService.ts @@ -1,4 +1,3 @@ -import { resolve } from '@avalabs/core-utils-sdk'; import { Asset, BitcoinConfigAsset, @@ -8,18 +7,13 @@ import { btcToSatoshi, Environment, estimateGas, - EthereumConfigAsset, fetchConfig, getBtcTransactionDetails, getMinimumConfirmations, - NativeAsset, setBridgeEnvironment, trackBridgeTransaction as trackBridgeTransactionSDK, TrackerSubscription, - transferAssetEVM as transferAssetSDK, - WrapStatus, } from '@avalabs/core-bridge-sdk'; -import { isNil, omit, omitBy } from 'lodash'; import { EventEmitter } from 'events'; import { NetworkService } from '../network/NetworkService'; import { StorageService } from '../storage/StorageService'; @@ -29,11 +23,7 @@ import { BridgeEvents, BridgeState, DefaultBridgeState, - TransferEventType, - BtcTransactionResponse, - CustomGasSettings, } from './models'; -import { WalletService } from '../wallet/WalletService'; import { AccountsService } from '../accounts/AccountsService'; import { singleton } from 'tsyringe'; import { @@ -41,13 +31,8 @@ import { OnStorageReady, } from '@src/background/runtime/lifecycleCallbacks'; import Big from 'big.js'; -import { NetworkFeeService } from '../networkFee/NetworkFeeService'; import { BalanceAggregatorService } from '../balances/BalanceAggregatorService'; -import { Avalanche, JsonRpcBatchInternal } from '@avalabs/core-wallets-sdk'; -import { isWalletConnectAccount } from '../accounts/utils/typeGuards'; import { FeatureGates } from '../featureFlags/models'; -import { wrapError } from '@src/utils/errors'; -import { getProviderForNetwork } from '@src/utils/network/getProviderForNetwork'; import { TokenWithBalanceBTC } from '../balances/models'; @singleton() @@ -71,10 +56,8 @@ export class BridgeService implements OnLock, OnStorageReady { constructor( private storageService: StorageService, private networkService: NetworkService, - private walletService: WalletService, private accountsService: AccountsService, private featureFlagService: FeatureFlagService, - private networkFeeService: NetworkFeeService, private networkBalancesService: BalanceAggregatorService ) { this.networkService.developerModeChanged.add(() => { @@ -187,120 +170,6 @@ export class BridgeService implements OnLock, OnStorageReady { ); } - async transferBtcAsset( - amount: Big, - customGasSettings?: CustomGasSettings, - tabId?: number - ): Promise { - if (!this.config?.config) { - throw new Error('Missing bridge config'); - } - - const { activeAccount } = this.accountsService; - - if (isWalletConnectAccount(activeAccount)) { - throw new Error('WalletConnect accounts are not supported by Bridge yet'); - } - - const addressBtc = activeAccount?.addressBTC; - - if (!addressBtc) { - throw new Error('No active account found'); - } - - const btcNetwork = await this.networkService.getBitcoinNetwork(); - - const provider = getProviderForNetwork(btcNetwork); - if ( - provider instanceof JsonRpcBatchInternal || - provider instanceof Avalanche.JsonRpcProvider - ) { - throw new Error('Wrong provider found.'); - } - - const amountInSatoshis = btcToSatoshi(amount); - - // mimicing the same feeRate in useBtcBridge - const feeRate = - customGasSettings?.maxFeePerGas ?? - Number( - (await this.networkFeeService.getNetworkFee(btcNetwork))?.high.maxFee ?? - 0 - ); - - const balances = await this.networkBalancesService.getBalancesForNetworks( - [btcNetwork.chainId], - [activeAccount] - ); - const token = balances[btcNetwork.chainId]?.[addressBtc]?.[ - 'BTC' - ] as TokenWithBalanceBTC; - - const utxos = token?.utxos ?? []; - - const { inputs, outputs } = getBtcTransactionDetails( - this.config.config, - addressBtc, - utxos, - amountInSatoshis, - Number(feeRate) - ); - - const inputsWithScripts = await provider.getScriptsForUtxos(inputs); - - const signResult = await this.walletService - .sign({ inputs: inputsWithScripts, outputs }, btcNetwork, tabId) - .catch(wrapError('Failed to sign transaction')); - - // If we received a signed tx, we need to issue it ourselves. - if (typeof signResult.signedTx === 'string') { - const [sendResult, sendError] = await resolve( - provider.issueRawTx(signResult.signedTx) - ); - - if (!sendResult || sendError) { - throw new Error('Failed to send transaction.'); - } - - const [tx, txError] = await resolve(provider.waitForTx(sendResult)); - - if (!tx || txError) { - throw new Error('Failed to fetch transaction details.'); - } - - return { - hash: tx.hash, - gasLimit: BigInt(tx.fees), - value: BigInt(amountInSatoshis), - confirmations: tx.confirmations, - from: addressBtc, - }; - } - - // If we received the tx hash, we can look it up for details. - if (typeof signResult.txHash === 'string') { - const [tx, txError] = await resolve( - provider.waitForTx(signResult.txHash) - ); - - if (!tx || txError) { - throw new Error('Transaction not found'); - } - - return { - hash: tx.hash, - gasLimit: BigInt(tx.fees), - value: BigInt(amountInSatoshis), - confirmations: tx.confirmations, - from: addressBtc, - }; - } - - throw new Error( - 'Unsupported signing result format. Signed TX or TX hash expected' - ); - } - async estimateGas( currentBlockchain: Blockchain, amount: Big, @@ -366,86 +235,6 @@ export class BridgeService implements OnLock, OnStorageReady { } } - async transferAsset( - currentBlockchain: Blockchain, - amount: Big, - asset: EthereumConfigAsset | NativeAsset, - customGasSettings?: CustomGasSettings, - tabId?: number - ): Promise { - if (!this.config?.config) { - throw new Error('missing bridge config'); - } - if (!this.accountsService.activeAccount) { - throw new Error('no active account found'); - } - this.featureFlagService.ensureFlagEnabled(FeatureGates.BRIDGE); - - const avalancheProvider = await this.networkService.getAvalancheProvider(); - const ethereumProvider = await this.networkService.getEthereumProvider(); - - if ( - Blockchain.AVALANCHE !== currentBlockchain && - Blockchain.ETHEREUM !== currentBlockchain - ) { - throw new Error('invalid current blockchain value'); - } - - // We have to get the network for the current blockchain - const network = - currentBlockchain === Blockchain.AVALANCHE - ? await this.networkService.getAvalancheNetwork() - : await this.networkService.getEthereumNetwork(); - - const sourceProvider = - currentBlockchain === Blockchain.AVALANCHE - ? avalancheProvider - : ethereumProvider; - const { addressC } = this.accountsService.activeAccount; - - return await transferAssetSDK({ - currentBlockchain, - amount, - account: addressC, - asset, - avalancheProvider, - ethereumProvider, - config: this.config.config, - onStatusChange: (status: WrapStatus) => - this.eventEmitter.emit(BridgeEvents.BRIDGE_TRANSFER_EVENT, { - type: TransferEventType.WRAP_STATUS, - status, - }), - onTxHashChange: (txHash: string) => - this.eventEmitter.emit(BridgeEvents.BRIDGE_TRANSFER_EVENT, { - type: TransferEventType.TX_HASH, - txHash, - }), - signAndSendEVM: async (txData) => { - // ignore our gas estimation, use whatever the SDK estimated at the time of signing - const gasSettings = omit(customGasSettings ?? {}, 'gasLimit'); - - const tx = { - ...txData, - // do not override gas-related properties with nullish values - ...omitBy(gasSettings ?? {}, isNil), - }; - const signingResult = await this.walletService.sign( - { - ...tx, - nonce: await sourceProvider.getTransactionCount(addressC), - gasPrice: tx.maxFeePerGas ? undefined : tx.gasPrice, // erase gasPrice if maxFeePerGas can be used - type: tx.maxFeePerGas ? undefined : 0, // use type: 0 if it's not an EIP-1559 transaction - }, - network, - tabId - ); - - return this.networkService.sendTransaction(signingResult, network); - }, - }); - } - async createTransaction( sourceChain: Blockchain, sourceTxHash: string, diff --git a/src/background/services/bridge/handlers/avalanche_bridgeAsset.test.ts b/src/background/services/bridge/handlers/avalanche_bridgeAsset.test.ts index a89ef8a12..98c933ba4 100644 --- a/src/background/services/bridge/handlers/avalanche_bridgeAsset.test.ts +++ b/src/background/services/bridge/handlers/avalanche_bridgeAsset.test.ts @@ -1,4 +1,11 @@ -import { Blockchain, BridgeConfig, getAssets } from '@avalabs/core-bridge-sdk'; +import { + Blockchain, + BridgeConfig, + btcToSatoshi, + getAssets, + transferAssetBTC, + transferAssetEVM, +} from '@avalabs/core-bridge-sdk'; import { ChainId } from '@avalabs/core-chains-sdk'; import { bnToBig, stringToBN } from '@avalabs/core-utils-sdk'; import { DAppProviderRequest } from '@src/background/connections/dAppConnection/models'; @@ -18,13 +25,17 @@ import { import { encryptAnalyticsData } from '../../analytics/utils/encryptAnalyticsData'; import { openApprovalWindow } from '@src/background/runtime/openApprovalWindow'; import { buildRpcCall } from '@src/tests/test-utils'; +import { FeatureGates } from '../../featureFlags/models'; +import { getBtcInputUtxos } from '@src/utils/send/btcSendUtils'; jest.mock('@src/background/runtime/openApprovalWindow'); - +jest.mock('@src/utils/send/btcSendUtils'); jest.mock('@avalabs/core-bridge-sdk', () => { const originalModule = jest.requireActual('@avalabs/core-bridge-sdk'); return { ...originalModule, + transferAssetBTC: jest.fn(), + transferAssetEVM: jest.fn(), getAssets: jest.fn(), }; }); @@ -37,8 +48,6 @@ const mockBridgeConfig: BridgeConfig = { config: mockConfig }; describe('background/services/bridge/handlers/avalanche_bridgeAsset', () => { const bridgeServiceMock = { - transferBtcAsset: jest.fn(), - transferAsset: jest.fn(), createTransaction: jest.fn(), estimateGas: jest.fn(), bridgeConfig: mockBridgeConfig, @@ -69,6 +78,24 @@ describe('background/services/bridge/handlers/avalanche_bridgeAsset', () => { allNetworks: { promisify: jest.fn(), }, + getAvalancheProvider: jest.fn(), + getEthereumProvider: jest.fn(), + getBitcoinProvider: jest.fn(), + } as any; + + const walletServiceMock = { + sign: jest.fn(), + } as any; + + const networkFeeServiceMock = { + getNetworkFee: jest.fn(), + } as any; + + const featureFlagServiceMock = { + featureFlags: { + [FeatureGates.BRIDGE]: true, + }, + ensureFlagEnabled: jest.fn(), } as any; const analyticsServicePosthogMock = { @@ -133,9 +160,11 @@ describe('background/services/bridge/handlers/avalanche_bridgeAsset', () => { beforeEach(() => { jest.resetAllMocks(); - bridgeServiceMock.transferBtcAsset.mockResolvedValue(btcResult); - bridgeServiceMock.transferAsset.mockResolvedValue(ethResult); bridgeServiceMock.createTransaction.mockResolvedValue(); + networkServiceMock.getBitcoinProvider.mockResolvedValue({ + waitForTx: jest.fn().mockResolvedValue(btcResult), + }); + balanceAggregatorServiceMock.getBalancesForNetworks.mockResolvedValue({}); jest.mocked(openApprovalWindow).mockResolvedValue(undefined); jest.mocked(getAssets).mockReturnValue({ BTC: btcAsset, @@ -147,9 +176,14 @@ describe('background/services/bridge/handlers/avalanche_bridgeAsset', () => { accountsServiceMock, balanceAggregatorServiceMock, networkServiceMock, - analyticsServicePosthogMock + analyticsServicePosthogMock, + walletServiceMock, + featureFlagServiceMock, + networkFeeServiceMock ); (encryptAnalyticsData as jest.Mock).mockResolvedValue(mockedEncryptResult); + + jest.mocked(getBtcInputUtxos).mockResolvedValue([]); }); describe('handleAuthenticated', () => { @@ -402,7 +436,10 @@ describe('background/services/bridge/handlers/avalanche_bridgeAsset', () => { accountsServiceMock, balanceServiceMock, networkServiceMock, - analyticsServicePosthogMock + analyticsServicePosthogMock, + walletServiceMock, + featureFlagServiceMock, + networkFeeServiceMock ); const mockRequest = { @@ -468,6 +505,7 @@ describe('background/services/bridge/handlers/avalanche_bridgeAsset', () => { describe('onActionApproved', () => { it('uses custom gas settings if provided', async () => { networkServiceMock.isMainnet.mockReturnValue(false); + networkServiceMock.getNetwork.mockReturnValue({ chainId: 5 } as any); const mockOnSuccess = jest.fn(); const mockOnError = jest.fn(); @@ -501,21 +539,21 @@ describe('background/services/bridge/handlers/avalanche_bridgeAsset', () => { frontendTabId ); - expect(bridgeServiceMock.transferAsset).toHaveBeenCalledTimes(1); - expect(bridgeServiceMock.transferAsset).toHaveBeenCalledWith( - ethAction.displayData.currentBlockchain, - amount, - ethAction.displayData.asset, - { - maxFeePerGas: 1337, - maxPriorityFeePerGas: 42, - }, - frontendTabId + expect(transferAssetEVM).toHaveBeenCalledTimes(1); + expect(transferAssetEVM).toHaveBeenCalledWith( + expect.objectContaining({ + currentBlockchain: ethAction.displayData.currentBlockchain, + amount, + asset: ethAction.displayData.asset, + }) ); }); - it('transferBtcAsset is called when network is Bitcoin', async () => { + it('transferAssetBTC is called when network is Bitcoin', async () => { networkServiceMock.isMainnet.mockReturnValue(true); + networkServiceMock.getNetwork.mockReturnValue({ chainId: 5 } as any); + + jest.mocked(transferAssetBTC).mockResolvedValue('0xhash'); const mockOnSuccess = jest.fn(); const mockOnError = jest.fn(); @@ -537,14 +575,13 @@ describe('background/services/bridge/handlers/avalanche_bridgeAsset', () => { frontendTabId ); - expect(bridgeServiceMock.transferBtcAsset).toHaveBeenCalledTimes(1); - expect(bridgeServiceMock.transferBtcAsset).toHaveBeenCalledWith( - amount, - undefined, - frontendTabId + expect(transferAssetBTC).toHaveBeenCalledTimes(1); + expect(transferAssetBTC).toHaveBeenCalledWith( + expect.objectContaining({ + amount: String(btcToSatoshi(amount)), + }) ); - expect(bridgeServiceMock.transferAsset).toHaveBeenCalledTimes(0); expect(bridgeServiceMock.createTransaction).toHaveBeenCalledTimes(1); expect(bridgeServiceMock.createTransaction).toHaveBeenCalledWith( Blockchain.BITCOIN, @@ -572,11 +609,12 @@ describe('background/services/bridge/handlers/avalanche_bridgeAsset', () => { ); }); - it('should call onError if transferBtcAsset throws errors', async () => { + it('should call onError if transferAssetBTC throws errors', async () => { networkServiceMock.isMainnet.mockReturnValue(false); + networkServiceMock.getNetwork.mockReturnValue({ chainId: 5 } as any); const error = new Error('error'); - bridgeServiceMock.transferBtcAsset.mockImplementation(() => { + jest.mocked(transferAssetBTC).mockImplementation(() => { return Promise.reject(error); }); @@ -603,8 +641,9 @@ describe('background/services/bridge/handlers/avalanche_bridgeAsset', () => { ); }); - it('transferAsset is called when network is not bitcoin', async () => { + it('transferAssetEVM is called when network is not bitcoin', async () => { networkServiceMock.isMainnet.mockReturnValue(false); + networkServiceMock.getNetwork.mockReturnValue({ chainId: 5 } as any); const mockOnSuccess = jest.fn(); const mockOnError = jest.fn(); @@ -619,6 +658,8 @@ describe('background/services/bridge/handlers/avalanche_bridgeAsset', () => { const now = Date.now(); jest.spyOn(Date, 'now').mockReturnValue(now); + jest.mocked(transferAssetEVM).mockResolvedValue('987hash987'); + await handler.onActionApproved( ethAction, {}, @@ -631,16 +672,14 @@ describe('background/services/bridge/handlers/avalanche_bridgeAsset', () => { balanceAggregatorServiceMock.getBalancesForNetworks ).toHaveBeenCalledTimes(0); - expect(bridgeServiceMock.transferAsset).toHaveBeenCalledTimes(1); - expect(bridgeServiceMock.transferAsset).toHaveBeenCalledWith( - ethAction.displayData.currentBlockchain, - amount, - ethAction.displayData.asset, - - undefined, - frontendTabId + expect(transferAssetEVM).toHaveBeenCalledTimes(1); + expect(transferAssetEVM).toHaveBeenCalledWith( + expect.objectContaining({ + currentBlockchain: ethAction.displayData.currentBlockchain, + amount: amount, + asset: ethAction.displayData.asset, + }) ); - expect(bridgeServiceMock.transferBtcAsset).toHaveBeenCalledTimes(0); expect(bridgeServiceMock.createTransaction).toHaveBeenCalledTimes(1); expect(bridgeServiceMock.createTransaction).toHaveBeenCalledWith( Blockchain.ETHEREUM, @@ -670,9 +709,10 @@ describe('background/services/bridge/handlers/avalanche_bridgeAsset', () => { it('should call onError if transferAsset throws errors', async () => { networkServiceMock.isMainnet.mockReturnValue(true); + networkServiceMock.getNetwork.mockReturnValue({ chainId: 1 } as any); const error = new Error('error'); - bridgeServiceMock.transferAsset.mockImplementation(() => { + jest.mocked(transferAssetEVM).mockImplementation(() => { return Promise.reject(error); }); @@ -682,7 +722,7 @@ describe('background/services/bridge/handlers/avalanche_bridgeAsset', () => { await handler.onActionApproved(ethAction, {}, mockOnSuccess, mockOnError); expect(bridgeServiceMock.createTransaction).toHaveBeenCalledTimes(0); - expect(mockOnError).toHaveBeenCalledWith(error); + expect(mockOnError).toHaveBeenCalledTimes(1); expect(mockOnSuccess).toHaveBeenCalledTimes(0); expect( diff --git a/src/background/services/bridge/handlers/avalanche_bridgeAsset.ts b/src/background/services/bridge/handlers/avalanche_bridgeAsset.ts index f6036c213..f94891679 100644 --- a/src/background/services/bridge/handlers/avalanche_bridgeAsset.ts +++ b/src/background/services/bridge/handlers/avalanche_bridgeAsset.ts @@ -1,13 +1,17 @@ import { NetworkService } from '@src/background/services/network/NetworkService'; import { AccountsService } from '@src/background/services/accounts/AccountsService'; import { + AppConfig, Asset, Assets, BitcoinConfigAsset, Blockchain, EthereumConfigAsset, + btcToSatoshi, getAssets, isNativeAsset, + transferAssetBTC, + transferAssetEVM, } from '@avalabs/core-bridge-sdk'; import Big from 'big.js'; import { bnToBig, stringToBN } from '@avalabs/core-utils-sdk'; @@ -25,6 +29,19 @@ import { openApprovalWindow } from '@src/background/runtime/openApprovalWindow'; import { isBitcoinNetwork } from '../../network/utils/isBitcoinNetwork'; import { AnalyticsServicePosthog } from '../../analytics/AnalyticsServicePosthog'; import { BridgeActionDisplayData } from '../models'; +import { WalletService } from '../../wallet/WalletService'; +import { ContractTransaction } from 'ethers'; +import { FeatureFlagService } from '../../featureFlags/FeatureFlagService'; +import { FeatureGates } from '../../featureFlags/models'; +import { isWalletConnectAccount } from '../../accounts/utils/typeGuards'; +import { NetworkFeeService } from '../../networkFee/NetworkFeeService'; +import { TokenWithBalanceBTC } from '../../balances/models'; +import { + buildBtcTx, + getBtcInputUtxos, + validateBtcSend, +} from '@src/utils/send/btcSendUtils'; +import { resolve } from '@src/utils/promiseResolver'; type BridgeActionParams = [ currentBlockchain: Blockchain, @@ -42,7 +59,10 @@ export class AvalancheBridgeAsset extends DAppRequestHandler private accountsService: AccountsService, private balanceAggregatorService: BalanceAggregatorService, private networkService: NetworkService, - private analyticsServicePosthog: AnalyticsServicePosthog + private analyticsServicePosthog: AnalyticsServicePosthog, + private walletService: WalletService, + private featureFlagService: FeatureFlagService, + private networkFeeService: NetworkFeeService ) { super(); } @@ -66,8 +86,7 @@ export class AvalancheBridgeAsset extends DAppRequestHandler } // map asset from params to bridge config asset - const bridgeConfig = this.bridgeService.bridgeConfig; - const config = bridgeConfig.config; + const config = this.#getConfig(); const assets: Assets | undefined = config && getAssets(currentBlockchain, config); const assetSymbol = @@ -190,6 +209,22 @@ export class AvalancheBridgeAsset extends DAppRequestHandler }; }; + #getSourceAccount = () => { + if (!this.accountsService.activeAccount) { + throw new Error('no active account found'); + } + + return this.accountsService.activeAccount; + }; + + #getConfig = (): AppConfig => { + if (!this.bridgeService.bridgeConfig.config) { + throw new Error('missing bridge config'); + } + + return this.bridgeService.bridgeConfig.config; + }; + #getSourceChainId = (currentBlockchain: Blockchain) => { const isMainnet = this.networkService.isMainnet(); @@ -220,39 +255,138 @@ export class AvalancheBridgeAsset extends DAppRequestHandler frontendTabId?: number ) => { const currentBlockchain = pendingAction?.displayData.currentBlockchain; + + if (currentBlockchain === Blockchain.UNKNOWN) { + onError('Unsupported blockchain'); + return; + } + try { + this.featureFlagService.ensureFlagEnabled(FeatureGates.BRIDGE); + } catch { + onError('Bridge feature is currently disabled'); + return; + } + const amountStr = pendingAction?.displayData.amountStr; const asset = pendingAction?.displayData.asset; const denomination = asset.denomination; const amount = bnToBig(stringToBN(amountStr, denomination), denomination); const sourceChainId = this.#getSourceChainId(currentBlockchain); + const network = await this.networkService.getNetwork(sourceChainId); + + if (!network) { + onError('Unsupported source network'); + return; + } if (currentBlockchain === Blockchain.BITCOIN) { try { - const result = await this.bridgeService.transferBtcAsset( - amount, - undefined, - frontendTabId + const account = this.#getSourceAccount(); + + if (isWalletConnectAccount(account)) { + throw new Error( + 'WalletConnect accounts are not supported by Bridge yet' + ); + } + const { addressBTC } = account; + + if (!addressBTC) { + throw new Error('No active account found'); + } + + const balances = + await this.balanceAggregatorService.getBalancesForNetworks( + [network.chainId], + [account] + ); + + const highFeeRate = Number( + (await this.networkFeeService.getNetworkFee(network))?.high.maxFee ?? + 0 ); + + const token = balances[network.chainId]?.[addressBTC]?.[ + 'BTC' + ] as TokenWithBalanceBTC; + + const btcProvider = await this.networkService.getBitcoinProvider(); + + const utxos = await getBtcInputUtxos(btcProvider, token, highFeeRate); + + const hash = await transferAssetBTC({ + config: this.#getConfig(), + amount: String(btcToSatoshi(amount)), + feeRate: highFeeRate, + onStatusChange: () => {}, + onTxHashChange: () => {}, + signAndSendBTC: async ([address, amountAsString, feeRate]) => { + const error = validateBtcSend( + addressBTC, + { + address, + amount: Number(amountAsString), + feeRate, + token, + }, + utxos, + !network.isTestnet + ); + + if (error) { + throw new Error( + 'Building BTC transaction for Bridge failed: ' + error + ); + } + + const { inputs, outputs } = await buildBtcTx( + addressBTC, + btcProvider, + { + amount: Number(amountAsString), + address, + token, + feeRate, + } + ); + + if (!inputs || !outputs) { + throw new Error('Unable to create transaction'); + } + + const result = await this.walletService.sign( + { inputs, outputs }, + network, + frontendTabId + ); + + return this.networkService.sendTransaction(result, network); + }, + }); + + const [tx, txError] = await resolve(btcProvider.waitForTx(hash)); + + if (!tx || txError) { + throw new Error('Failed to fetch transaction details.'); + } + await this.bridgeService.createTransaction( Blockchain.BITCOIN, - result.hash, + tx.hash, Date.now(), Blockchain.AVALANCHE, amount, 'BTC' ); - this.analyticsServicePosthog.captureEncryptedEvent({ name: 'avalanche_bridgeAsset_success', windowId: crypto.randomUUID(), properties: { address: this.accountsService.activeAccount?.addressBTC, - txHash: result.hash, + txHash: tx.hash, chainId: sourceChainId, }, }); - - onSuccess(result); + onSuccess(tx); } catch (e) { this.analyticsServicePosthog.captureEncryptedEvent({ name: 'avalanche_bridgeAsset_failed', @@ -262,18 +396,41 @@ export class AvalancheBridgeAsset extends DAppRequestHandler chainId: sourceChainId, }, }); - onError(e); } } else { try { - const txHash = await this.bridgeService.transferAsset( + const txHash = await transferAssetEVM({ currentBlockchain, amount, - asset as Exclude, - pendingAction.displayData.gasSettings, - frontendTabId - ); + account: this.#getSourceAccount().addressC, + asset, + avalancheProvider: await this.networkService.getAvalancheProvider(), + ethereumProvider: await this.networkService.getEthereumProvider(), + config: this.#getConfig(), + signAndSendEVM: async (txData) => { + const tx = txData as ContractTransaction; // TODO: update types in the SDK? + + const signResult = await this.walletService.sign( + { + ...tx, + // erase gasPrice if maxFeePerGas can be used + gasPrice: tx.maxFeePerGas + ? undefined + : tx.gasPrice ?? undefined, + type: tx.maxFeePerGas ? undefined : 0, // use type: 0 if it's not an EIP-1559 transaction + }, + network, + frontendTabId + ); + + return this.networkService.sendTransaction(signResult, network); + }, + // Unused, but required. + onStatusChange: () => {}, + onTxHashChange: () => {}, + }); + if (txHash) { await this.bridgeService.createTransaction( currentBlockchain, diff --git a/src/background/services/bridge/handlers/getEthMaxTransferAmount.test.ts b/src/background/services/bridge/handlers/getEthMaxTransferAmount.test.ts deleted file mode 100644 index 34252e27e..000000000 --- a/src/background/services/bridge/handlers/getEthMaxTransferAmount.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { - Blockchain, - getAssets, - getMaxTransferAmount, -} from '@avalabs/core-bridge-sdk'; -import { JsonRpcBatchInternal } from '@avalabs/core-wallets-sdk'; -import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; -import Big from 'big.js'; -import { BN } from 'bn.js'; -import { TokenType, TokenWithBalance } from '../../balances/models'; -import { GetEthMaxTransferAmountHandler } from './getEthMaxTransferAmount'; -import { getProviderForNetwork } from '@src/utils/network/getProviderForNetwork'; -import { buildRpcCall } from '@src/tests/test-utils'; -import { ChainId } from '@avalabs/core-chains-sdk'; - -jest.mock('@avalabs/core-bridge-sdk', () => { - const originalModule = jest.requireActual('@avalabs/core-bridge-sdk'); - return { - ...originalModule, - getAssets: jest.fn(), - getMaxTransferAmount: jest.fn(), - }; -}); - -jest.mock('@src/utils/network/getProviderForNetwork'); - -describe('background/services/bridge/handlers/getEthMaxTransferAmount', () => { - const mockProvider = new JsonRpcBatchInternal(1); - const networkServiceMock = { - getNetwork: jest.fn(), - getProviderForNetwork: jest.fn(), - } as any; - const bridgeServiceMock = { - bridgeConfig: { - config: { - critical: { - assets: [{ symbol: 'WETH' }], - }, - }, - }, - } as any; - const balanceAggregatorServiceMock = { - getBalancesForNetworks: async () => ({ - '1': { - '0x11111eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee': [ - { - type: TokenType.NATIVE, - balance: new BN('1000000'), - decimals: 4, - name: 'NativeTestToken', - symbol: 'NTT', - description: '', - logoUri: '', - }, - ] as TokenWithBalance[], - }, - }), - } as any; - const accountsServiceMock = { - activeAccount: { - addressC: '0x11111eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', - }, - } as any; - const scope = `eip155:${ChainId.ETHEREUM_HOMESTEAD}`; - - beforeEach(() => { - jest.resetAllMocks(); - jest.mocked(getProviderForNetwork).mockReturnValue(mockProvider); - - jest - .mocked(networkServiceMock.getNetwork) - .mockResolvedValue({ chainId: ChainId.ETHEREUM_HOMESTEAD }); - }); - - it('returns error when network is not set', async () => { - const handler = new GetEthMaxTransferAmountHandler( - bridgeServiceMock, - networkServiceMock, - balanceAggregatorServiceMock, - accountsServiceMock - ); - const request = { - id: '1', - method: ExtensionRequest.BRIDGE_GET_ETH_MAX_TRANSFER_AMOUNT, - params: ['NTT'], - } as any; - - jest - .spyOn(networkServiceMock, 'getNetwork') - .mockResolvedValueOnce(undefined); - - const result = await handler.handle(buildRpcCall(request, scope)); - - expect(result).toEqual({ - ...request, - error: 'network or account not found', - }); - }); - - it('returns error when account is not found', async () => { - const handler = new GetEthMaxTransferAmountHandler( - bridgeServiceMock, - networkServiceMock, - balanceAggregatorServiceMock, - { activeAccount: undefined } as any - ); - const request = { - id: '1', - method: ExtensionRequest.BRIDGE_GET_ETH_MAX_TRANSFER_AMOUNT, - params: ['NTT'], - } as any; - const result = await handler.handle(buildRpcCall(request, scope)); - - expect(result).toEqual({ - ...request, - error: 'network or account not found', - }); - }); - - it('returns error when not on ethereum network', async () => { - jest - .mocked(networkServiceMock.getNetwork) - .mockResolvedValue({ chainId: ChainId.AVALANCHE_MAINNET_ID }); - - const handler = new GetEthMaxTransferAmountHandler( - bridgeServiceMock, - networkServiceMock, - balanceAggregatorServiceMock, - accountsServiceMock - ); - const request = { - id: '1', - method: ExtensionRequest.BRIDGE_GET_ETH_MAX_TRANSFER_AMOUNT, - params: ['NTT'], - } as any; - const result = await handler.handle(buildRpcCall(request, 'eip155:43114')); - - expect(result).toEqual({ - ...request, - error: 'not on ethereum network', - }); - }); - - it('returns error if token not found', async () => { - const handler = new GetEthMaxTransferAmountHandler( - bridgeServiceMock, - networkServiceMock, - balanceAggregatorServiceMock, - accountsServiceMock - ); - const request = { - id: '1', - method: ExtensionRequest.BRIDGE_GET_ETH_MAX_TRANSFER_AMOUNT, - params: ['UNKOWN'], - } as any; - const result = await handler.handle(buildRpcCall(request, scope)); - - expect(result).toEqual({ - ...request, - error: 'unable to determine max amount', - }); - }); - - it('returns error when provider is missing', async () => { - const handler = new GetEthMaxTransferAmountHandler( - bridgeServiceMock, - networkServiceMock, - balanceAggregatorServiceMock, - accountsServiceMock - ); - - // make sure provider is not a JsonRpcBatchInternal - jest.mocked(getProviderForNetwork).mockReturnValue({} as any); - const request = { - id: '1', - method: ExtensionRequest.BRIDGE_GET_ETH_MAX_TRANSFER_AMOUNT, - params: ['NTT'], - } as any; - const result = await handler.handle(buildRpcCall(request, scope)); - - expect(result).toEqual({ - ...request, - error: 'unable to determine max amount', - }); - }); - - it('returns error when bridge config is missing', async () => { - const handler = new GetEthMaxTransferAmountHandler( - { - bridgeConfig: { - config: undefined, - }, - } as any, - networkServiceMock, - balanceAggregatorServiceMock, - accountsServiceMock - ); - const request = { - id: '1', - method: ExtensionRequest.BRIDGE_GET_ETH_MAX_TRANSFER_AMOUNT, - params: ['NTT'], - } as any; - const result = await handler.handle(buildRpcCall(request, scope)); - - expect(result).toEqual({ - ...request, - error: 'unable to determine max amount', - }); - }); - - it('catches errors when config is missing', async () => { - class BridgeServiceErrorMock { - public get bridgeConfig(): any { - throw new Error('bridge config error'); - } - } - - const handler = new GetEthMaxTransferAmountHandler( - new BridgeServiceErrorMock() as any, - networkServiceMock, - balanceAggregatorServiceMock, - accountsServiceMock - ); - const request = { - id: '1', - method: ExtensionRequest.BRIDGE_GET_ETH_MAX_TRANSFER_AMOUNT, - params: ['NTT'], - } as any; - const result = await handler.handle(buildRpcCall(request, scope)); - - expect(result).toEqual({ - ...request, - error: 'Error: bridge config error', - }); - }); - - it('calculates max transfer amount', async () => { - const handler = new GetEthMaxTransferAmountHandler( - bridgeServiceMock, - networkServiceMock, - balanceAggregatorServiceMock, - accountsServiceMock - ); - - (getMaxTransferAmount as jest.Mock).mockResolvedValue(new Big(1000)); - const ethAssets = Symbol('ethAssets'); - (getAssets as jest.Mock).mockReturnValue(ethAssets); - - const request = { - id: '1', - method: ExtensionRequest.BRIDGE_GET_ETH_MAX_TRANSFER_AMOUNT, - params: ['NTT'], - } as any; - const result = await handler.handle(buildRpcCall(request, scope)); - - expect(result).toEqual({ - ...request, - result: new Big(1000), - }); - expect(getMaxTransferAmount).toHaveBeenCalledTimes(1); - expect(getMaxTransferAmount).toHaveBeenCalledWith({ - currentBlockchain: Blockchain.ETHEREUM, - balance: new Big('100'), - currentAsset: 'NTT', - assets: ethAssets, - provider: mockProvider, - config: bridgeServiceMock.bridgeConfig.config, - }); - }); -}); diff --git a/src/background/services/bridge/handlers/getEthMaxTransferAmount.ts b/src/background/services/bridge/handlers/getEthMaxTransferAmount.ts deleted file mode 100644 index 3493802a5..000000000 --- a/src/background/services/bridge/handlers/getEthMaxTransferAmount.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - Blockchain, - getAssets, - getMaxTransferAmount as getEVMMaxTransferAmount, -} from '@avalabs/core-bridge-sdk'; -import { bnToBig } from '@avalabs/core-utils-sdk'; -import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; -import { ExtensionRequestHandler } from '@src/background/connections/models'; -import { networkToBlockchain } from '@src/pages/Bridge/utils/blockchainConversion'; -import Big from 'big.js'; -import { injectable } from 'tsyringe'; -import { NetworkService } from '../../network/NetworkService'; -import { BridgeService } from '../BridgeService'; -import { JsonRpcBatchInternal } from '@avalabs/core-wallets-sdk'; -import { BalanceAggregatorService } from '../../balances/BalanceAggregatorService'; -import { AccountsService } from '../../accounts/AccountsService'; -import { getProviderForNetwork } from '@src/utils/network/getProviderForNetwork'; - -type HandlerType = ExtensionRequestHandler< - ExtensionRequest.BRIDGE_GET_ETH_MAX_TRANSFER_AMOUNT, - Big | null, - [currentAsset: string] ->; - -@injectable() -export class GetEthMaxTransferAmountHandler implements HandlerType { - method = ExtensionRequest.BRIDGE_GET_ETH_MAX_TRANSFER_AMOUNT as const; - - constructor( - private bridgeService: BridgeService, - private networkService: NetworkService, - private balanceAggregatorService: BalanceAggregatorService, - private accountsService: AccountsService - ) {} - - handle: HandlerType['handle'] = async ({ request, scope }) => { - const [currentAsset] = request.params; - - const activeNetwork = await this.networkService.getNetwork(scope); - const activeAccount = this.accountsService.activeAccount; - const currentBlockchain = networkToBlockchain(activeNetwork); - - if (!activeNetwork || !activeAccount) { - return { - ...request, - error: 'network or account not found', - }; - } - - if (currentBlockchain !== Blockchain.ETHEREUM) { - return { - ...request, - error: 'not on ethereum network', - }; - } - - const balances = await this.balanceAggregatorService.getBalancesForNetworks( - [activeNetwork.chainId], - [activeAccount] - ); - const token = Object.values( - balances[activeNetwork?.chainId]?.[activeAccount?.addressC] ?? {} - )?.find(({ symbol }) => symbol === currentAsset); - - try { - const config = this.bridgeService.bridgeConfig.config; - const provider = getProviderForNetwork(activeNetwork); - - if (!config || !(provider instanceof JsonRpcBatchInternal) || !token) { - return { - ...request, - error: 'unable to determine max amount', - }; - } - const ethereumAssets = getAssets(currentBlockchain, config); - - const requiredForGas = await getEVMMaxTransferAmount({ - currentBlockchain, - balance: bnToBig(token.balance, token.decimals), - currentAsset, - assets: ethereumAssets, - provider, - config, - }); - - return { - ...request, - result: requiredForGas, - }; - } catch (e: any) { - return { - ...request, - error: e.toString(), - }; - } - }; -} diff --git a/src/background/services/bridge/handlers/transferAsset.ts b/src/background/services/bridge/handlers/transferAsset.ts deleted file mode 100644 index 7210f554c..000000000 --- a/src/background/services/bridge/handlers/transferAsset.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Asset, Blockchain } from '@avalabs/core-bridge-sdk'; -import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; -import { ExtensionRequestHandler } from '@src/background/connections/models'; -import Big from 'big.js'; -import { injectable } from 'tsyringe'; -import { BridgeService } from '../BridgeService'; -import { CustomGasSettings } from '../models'; -import { ethErrors } from 'eth-rpc-errors'; -import { CommonError, isWrappedError } from '@src/utils/errors'; - -type HandlerType = ExtensionRequestHandler< - ExtensionRequest.BRIDGE_TRANSFER_ASSET, - { hash: string }, - [ - currentBlockchain: Blockchain, - amountStr: Big, - asset: Asset, - customGasSettings?: CustomGasSettings - ] ->; - -@injectable() -export class BridgeTransferAssetHandler implements HandlerType { - method = ExtensionRequest.BRIDGE_TRANSFER_ASSET as const; - - constructor(private bridgeService: BridgeService) {} - - handle: HandlerType['handle'] = async ({ request }) => { - const [currentBlockchain, amount, asset, customGasSettings] = - request.params; - - try { - if (currentBlockchain === Blockchain.BITCOIN) { - return { - ...request, - result: await this.bridgeService.transferBtcAsset( - amount, - customGasSettings, - request.tabId - ), - }; - } else { - const txHash = await this.bridgeService.transferAsset( - currentBlockchain, - amount, - // This is needed for the bridge to work currently - asset as any, - customGasSettings, - request.tabId - ); - - if (!txHash) { - return { - ...request, - error: ethErrors.rpc.internal({ - data: { reason: CommonError.Unknown }, - }), - }; - } - - return { - ...request, - result: { - hash: txHash, - }, - }; - } - } catch (err) { - return { - ...request, - error: - isWrappedError(err) || typeof err === 'string' - ? err - : ethErrors.rpc.internal({ data: { reason: CommonError.Unknown } }), - }; - } - }; -} diff --git a/src/background/services/debank/utils/txParamsToTransactionData.ts b/src/background/services/debank/utils/txParamsToTransactionData.ts index 32d1b2140..5c8f0d97e 100644 --- a/src/background/services/debank/utils/txParamsToTransactionData.ts +++ b/src/background/services/debank/utils/txParamsToTransactionData.ts @@ -1,3 +1,4 @@ +import { toBeHex } from 'ethers'; import { Network } from '@avalabs/core-chains-sdk'; import { DebankTransactionData } from '../models'; import { EthSendTransactionParams } from '../../wallet/handlers/eth_sendTransaction/models'; @@ -10,7 +11,7 @@ export function txParamsToTransactionData( from: tx.from, to: tx.to ?? '0x', data: tx.data ?? '0x', - value: tx.value ?? '0x', + value: tx.value ? toBeHex(tx.value) : '0x', chainId: network.chainId, gas: `0x${BigInt(tx.gas ?? 0).toString(16)}`, nonce: tx.nonce ? tx.nonce : `0x1`, diff --git a/src/background/services/unifiedBridge/UnifiedBridgeService.test.ts b/src/background/services/unifiedBridge/UnifiedBridgeService.test.ts index 60dea1bf6..628141724 100644 --- a/src/background/services/unifiedBridge/UnifiedBridgeService.test.ts +++ b/src/background/services/unifiedBridge/UnifiedBridgeService.test.ts @@ -4,16 +4,11 @@ import { } from '@avalabs/bridge-unified'; import { UnifiedBridgeService } from './UnifiedBridgeService'; import { FeatureGates } from '../featureFlags/models'; -import { chainIdToCaip } from '@src/utils/caipConversion'; -import { ethErrors } from 'eth-rpc-errors'; -import { CommonError } from '@src/utils/errors'; -import { getProviderForNetwork } from '@src/utils/network/getProviderForNetwork'; jest.mock('@avalabs/bridge-unified'); jest.mock('@src/utils/network/getProviderForNetwork'); describe('src/background/services/unifiedBridge/UnifiedBridgeService', () => { - let service: UnifiedBridgeService; let core: ReturnType; const trackTransfer = jest.fn(); @@ -31,21 +26,6 @@ describe('src/background/services/unifiedBridge/UnifiedBridgeService', () => { sendTransaction: jest.fn(), } as any; - const accountsService = { - activeAccount: { - addressC: 'addressC', - }, - } as any; - - const walletService = { - sign: jest.fn(), - } as any; - - const feeService = { - getNetworkFee: jest.fn(), - estimateGasLimit: jest.fn(), - } as any; - const storageService = { load: jest.fn(), save: jest.fn(), @@ -79,27 +59,13 @@ describe('src/background/services/unifiedBridge/UnifiedBridgeService', () => { chainId, })); - service = new UnifiedBridgeService( - networkService, - accountsService, - walletService, - feeService, - storageService, - flagsService - ); + new UnifiedBridgeService(networkService, storageService, flagsService); }); it('creates core instance with proper environment', () => { networkService.isMainnet.mockReturnValue(true); - new UnifiedBridgeService( - networkService, - accountsService, - walletService, - feeService, - storageService, - flagsService - ); + new UnifiedBridgeService(networkService, storageService, flagsService); expect(createUnifiedBridgeService).toHaveBeenCalledWith( expect.objectContaining({ @@ -109,14 +75,7 @@ describe('src/background/services/unifiedBridge/UnifiedBridgeService', () => { networkService.isMainnet.mockReturnValue(false); - new UnifiedBridgeService( - networkService, - accountsService, - walletService, - feeService, - storageService, - flagsService - ); + new UnifiedBridgeService(networkService, storageService, flagsService); expect(createUnifiedBridgeService).toHaveBeenCalledWith( expect.objectContaining({ @@ -166,9 +125,6 @@ describe('src/background/services/unifiedBridge/UnifiedBridgeService', () => { const bridgeService = new UnifiedBridgeService( networkService, - accountsService, - walletService, - feeService, storageService, flagsService ); @@ -180,321 +136,4 @@ describe('src/background/services/unifiedBridge/UnifiedBridgeService', () => { expect.objectContaining({ bridgeTransfer: { sourceTxHash: '0x2345' } }) ); }); - - describe('.transfer()', () => { - beforeEach(() => { - networkService.sendTransaction - .mockResolvedValueOnce('approveTxHash') - .mockResolvedValueOnce('transferTxHash'); - - trackTransfer.mockReturnValue({ - result: Promise.resolve({ sourceTxHash: 'transferTxHash' }), - }); - - transferAsset.mockImplementation( - async ({ fromAddress, onStepChange, sign }) => { - onStepChange({ currentSignature: 1, requiredSignatures: 2 }); - await sign({ - from: fromAddress, - to: 'toAddress', - data: 'approval-data', - }); - - onStepChange({ currentSignature: 2, requiredSignatures: 2 }); - await sign({ - from: fromAddress, - to: 'toAddress', - data: 'transfer-data', - }); - - return { - sourceTxHash: 'transferTxHash', - }; - } - ); - }); - - const asset = { address: 'adderss' } as any; - - describe('when custom gas settings are passed', () => { - const highMarketFee = { - maxFee: 1000n, - maxTip: 10n, - }; - - const estimatedGasLimits = [55_000n, 165_000n]; - - beforeEach(() => { - feeService.getNetworkFee.mockResolvedValue({ - high: highMarketFee, - }); - - feeService.estimateGasLimit - .mockResolvedValueOnce(estimatedGasLimits[0]) - .mockResolvedValueOnce(estimatedGasLimits[1]); - - jest.mocked(getProviderForNetwork).mockReturnValue({ - getTransactionCount: jest - .fn() - .mockResolvedValueOnce(5) - .mockResolvedValueOnce(6), - } as any); - - walletService.sign - .mockResolvedValueOnce({ signedTx: 'approval-tx-hex' }) - .mockResolvedValueOnce({ signedTx: 'transfer-tx-hex' }); - }); - - it('applies the user-provided maxFeePerGas and maxPriorityFeePerGas', async () => { - const maxFeePerGas = BigInt(50e9); - const maxPriorityFeePerGas = BigInt(5e9); - - await service.transfer({ - asset, - amount: 1000000n, - sourceChainId: 43113, - targetChainId: 5, - tabId: 1234, - customGasSettings: { - maxFeePerGas, - maxPriorityFeePerGas, - }, - }); - - expect(walletService.sign).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - maxFeePerGas, - maxPriorityFeePerGas, - }), - { chainId: 43113 }, - 1234 - ); - - expect(walletService.sign).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - maxFeePerGas, - maxPriorityFeePerGas, - }), - { chainId: 43113 }, - 1234 - ); - }); - - it('ignores the user-provided gasLimit', async () => { - const maxFeePerGas = BigInt(50e9); - const maxPriorityFeePerGas = BigInt(5e9); - const gasLimit = 50_000n; - - await service.transfer({ - asset, - amount: 1000000n, - sourceChainId: 43113, - targetChainId: 5, - tabId: 1234, - customGasSettings: { - maxFeePerGas, - maxPriorityFeePerGas, - gasLimit, - }, - }); - - expect(walletService.sign).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - maxFeePerGas, - maxPriorityFeePerGas, - gasLimit: estimatedGasLimits[0], - }), - { chainId: 43113 }, - 1234 - ); - - expect(walletService.sign).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - maxFeePerGas, - maxPriorityFeePerGas, - gasLimit: estimatedGasLimits[1], - }), - { chainId: 43113 }, - 1234 - ); - }); - }); - - describe('when network fee is unknown', () => { - beforeEach(() => { - feeService.getNetworkFee.mockResolvedValue(null); - }); - - it('raises UnknownNetworkFee error', async () => { - await expect( - service.transfer({ - asset, - amount: 1000000n, - sourceChainId: 43113, - targetChainId: 5, - tabId: 1234, - }) - ).rejects.toThrow( - ethErrors.rpc.internal({ - data: { reason: CommonError.UnknownNetworkFee }, - }) - ); - }); - }); - - describe('during happy path', () => { - beforeEach(() => { - feeService.getNetworkFee.mockResolvedValue({ - high: { - maxFee: 1000n, - maxTip: 10n, - }, - }); - feeService.estimateGasLimit.mockResolvedValue(2000); - }); - - it('calls .transferAsset() with proper params', async () => { - jest.mocked(getProviderForNetwork).mockReturnValue({ - getTransactionCount: jest - .fn() - .mockResolvedValueOnce(5) - .mockResolvedValueOnce(6), - } as any); - - walletService.sign - .mockResolvedValueOnce({ signedTx: 'approval-tx-hex' }) - .mockResolvedValueOnce({ signedTx: 'transfer-tx-hex' }); - - await service.transfer({ - asset, - amount: 1000000n, - sourceChainId: 43113, - targetChainId: 5, - tabId: 1234, - }); - - expect(core.transferAsset).toHaveBeenCalledWith({ - asset, - fromAddress: accountsService.activeAccount.addressC, - amount: 1000000n, - sourceChain: expect.objectContaining({ - chainId: chainIdToCaip(43113), - }), - targetChain: expect.objectContaining({ chainId: chainIdToCaip(5) }), - onStepChange: expect.any(Function), - sign: expect.any(Function), - }); - - expect(walletService.sign).toHaveBeenCalledTimes(2); - expect(walletService.sign).toHaveBeenNthCalledWith( - 1, - { - from: accountsService.activeAccount.addressC, - to: 'toAddress', - data: 'approval-data', - chainId: 43113, - gasLimit: 2000, - maxFeePerGas: 1000n, - maxPriorityFeePerGas: 10n, - nonce: 5, - }, - { chainId: 43113 }, - 1234 - ); - expect(walletService.sign).toHaveBeenNthCalledWith( - 2, - { - from: accountsService.activeAccount.addressC, - to: 'toAddress', - data: 'transfer-data', - chainId: 43113, - gasLimit: 2000, - maxFeePerGas: 1000n, - maxPriorityFeePerGas: 10n, - nonce: 6, - }, - { chainId: 43113 }, - 1234 - ); - - expect(networkService.sendTransaction).toHaveBeenCalledTimes(2); - expect(networkService.sendTransaction).toHaveBeenNthCalledWith( - 1, - { - signedTx: 'approval-tx-hex', - }, - { chainId: 43113 } - ); - expect(networkService.sendTransaction).toHaveBeenNthCalledWith( - 2, - { - signedTx: 'transfer-tx-hex', - }, - { chainId: 43113 } - ); - }); - }); - }); - - describe('.getFee()', () => { - const asset = { - address: 'address', - }; - const fee = 2000000n; - - beforeEach(() => { - getFees.mockResolvedValue({ - [asset.address]: fee, - }); - }); - - it(`properly calls SDK's getFees() and returns the same value`, async () => { - const result = await service.getFee({ - asset, - amount: 100n, - sourceChainId: 43113, - targetChainId: 5, - }); - - expect(result).toEqual(fee); - expect(core.getFees).toHaveBeenCalledWith({ - asset, - amount: 100n, - sourceChain: expect.objectContaining({ chainId: chainIdToCaip(43113) }), - targetChain: expect.objectContaining({ chainId: chainIdToCaip(5) }), - }); - }); - }); - - describe('.estimateGas()', () => { - const asset = { - address: 'address', - } as any; - - beforeEach(() => { - estimateGas.mockResolvedValue(12345n); - }); - - it(`properly calls SDK's estimateGas() and returns the same value`, async () => { - const result = await service.estimateGas({ - asset, - amount: 100n, - sourceChainId: 43113, - targetChainId: 5, - }); - - expect(result).toEqual(12345n); - expect(core.estimateGas).toHaveBeenCalledWith({ - asset, - amount: 100n, - fromAddress: accountsService.activeAccount.addressC, - sourceChain: expect.objectContaining({ chainId: chainIdToCaip(43113) }), - targetChain: expect.objectContaining({ chainId: chainIdToCaip(5) }), - }); - }); - }); }); diff --git a/src/background/services/unifiedBridge/UnifiedBridgeService.ts b/src/background/services/unifiedBridge/UnifiedBridgeService.ts index 1ecdd5884..17487fc97 100644 --- a/src/background/services/unifiedBridge/UnifiedBridgeService.ts +++ b/src/background/services/unifiedBridge/UnifiedBridgeService.ts @@ -2,37 +2,22 @@ import { singleton } from 'tsyringe'; import { BridgeTransfer, BridgeType, - Chain, - ChainAssetMap, createUnifiedBridgeService, Environment, - TokenType, } from '@avalabs/bridge-unified'; -import { JsonRpcApiProvider, TransactionRequest } from 'ethers'; -import { ethErrors } from 'eth-rpc-errors'; import EventEmitter from 'events'; -import { chainIdToCaip } from '@src/utils/caipConversion'; import { OnStorageReady } from '@src/background/runtime/lifecycleCallbacks'; -import { CommonError } from '@src/utils/errors'; - -import { WalletService } from '../wallet/WalletService'; import { NetworkService } from '../network/NetworkService'; -import { AccountsService } from '../accounts/AccountsService'; -import { NetworkFeeService } from '../networkFee/NetworkFeeService'; import { StorageService } from '../storage/StorageService'; import { UNIFIED_BRIDGE_DEFAULT_STATE, UNIFIED_BRIDGE_STATE_STORAGE_KEY, UNIFIED_BRIDGE_TRACKED_FLAGS, - UnifiedBridgeError, - UnifiedBridgeEstimateGasParams, UnifiedBridgeEvent, UnifiedBridgeState, - UnifiedBridgeTransferParams, } from './models'; -import { isBitcoinNetwork } from '../network/utils/isBitcoinNetwork'; import { FeatureFlagService } from '../featureFlags/FeatureFlagService'; import { FeatureFlagEvents, @@ -42,9 +27,6 @@ import { import sentryCaptureException, { SentryExceptionTypes, } from '@src/monitoring/sentryCaptureException'; -import { FeeRate } from '../networkFee/models'; -import { getProviderForNetwork } from '@src/utils/network/getProviderForNetwork'; -import { JsonRpcBatchInternal } from '@avalabs/core-wallets-sdk'; @singleton() export class UnifiedBridgeService implements OnStorageReady { @@ -59,15 +41,8 @@ export class UnifiedBridgeService implements OnStorageReady { return this.#state; } - async getAssets(): Promise { - return this.#core.getAssets(); - } - constructor( private networkService: NetworkService, - private accountsService: AccountsService, - private walletService: WalletService, - private feeService: NetworkFeeService, private storageService: StorageService, private featureFlagService: FeatureFlagService ) { @@ -111,6 +86,7 @@ export class UnifiedBridgeService implements OnStorageReady { )) ?? UNIFIED_BRIDGE_DEFAULT_STATE; this.#saveState(state); + this.#trackPendingTransfers(); } #trackPendingTransfers() { @@ -165,10 +141,6 @@ export class UnifiedBridgeService implements OnStorageReady { disabledBridgeTypes: this.#getDisabledBridges(), }); core.init().then(async () => { - this.#eventEmitter.emit( - UnifiedBridgeEvent.AssetsUpdated, - await core.getAssets() - ); this.#updateBridgeAddresses(); this.#trackPendingTransfers(); }); @@ -176,197 +148,6 @@ export class UnifiedBridgeService implements OnStorageReady { return core; } - async getFee({ - asset, - amount, - sourceChainId, - targetChainId, - }): Promise { - const feeMap = await this.#core.getFees({ - asset, - amount, - targetChain: await this.#buildChain(targetChainId), - sourceChain: await this.#buildChain(sourceChainId), - }); - - const fee = feeMap[asset.address]; - - if (typeof fee !== 'bigint') { - throw ethErrors.rpc.invalidRequest({ - data: { - reason: UnifiedBridgeError.InvalidFee, - }, - }); - } - - return fee; - } - - async estimateGas({ - asset, - amount, - sourceChainId, - targetChainId, - }: UnifiedBridgeEstimateGasParams): Promise { - const { fromAddress, sourceChain, targetChain } = await this.#buildParams({ - targetChainId, - sourceChainId, - }); - - const gasLimit = await this.#core.estimateGas({ - asset, - fromAddress, - amount, - sourceChain, - targetChain, - }); - - return gasLimit; - } - - async #buildParams({ targetChainId, sourceChainId }): - | Promise<{ - sourceChain: Chain; - sourceChainId: number; - targetChain: Chain; - provider: JsonRpcApiProvider; - fromAddress: `0x${string}`; - }> - | never { - const { activeAccount } = this.accountsService; - - if (!activeAccount) { - throw ethErrors.rpc.invalidParams({ - data: { - reason: CommonError.NoActiveAccount, - }, - }); - } - - const network = await this.networkService.getNetwork(sourceChainId); - - if (!network) { - throw ethErrors.rpc.invalidParams({ - data: { - reason: CommonError.NoActiveNetwork, - }, - }); - } - - if (isBitcoinNetwork(network)) { - throw ethErrors.rpc.invalidParams({ - data: { - reason: UnifiedBridgeError.UnsupportedNetwork, - }, - }); - } - - const sourceChain = await this.#buildChain(sourceChainId); - const targetChain = await this.#buildChain(targetChainId); - - const provider = getProviderForNetwork(network) as JsonRpcBatchInternal; - - const fromAddress = activeAccount.addressC as `0x${string}`; - - return { - sourceChain, - sourceChainId, - targetChain, - provider, - fromAddress, - }; - } - - async transfer({ - asset, - amount, - targetChainId, - sourceChainId, - customGasSettings, - tabId, - }: UnifiedBridgeTransferParams): Promise { - const { fromAddress, provider, sourceChain, targetChain } = - await this.#buildParams({ targetChainId, sourceChainId }); - - const bridgeTransfer = await this.#core.transferAsset({ - asset, - fromAddress, - amount, - sourceChain, - targetChain, - onStepChange: (stepDetails) => { - this.#eventEmitter.emit( - UnifiedBridgeEvent.TransferStepChange, - stepDetails - ); - }, - sign: async ({ from, to, data }) => { - let feeRate: FeeRate = { - maxFee: customGasSettings?.maxFeePerGas ?? 0n, - maxTip: customGasSettings?.maxPriorityFeePerGas ?? 0n, - }; - - // If we have no custom fee rate, fetch it from the network - const network = await this.networkService.getNetwork(sourceChainId); - - if (!network) { - throw ethErrors.rpc.internal({ - data: { reason: CommonError.UnknownNetwork }, - }); - } - - if (!feeRate.maxFee) { - const networkFee = await this.feeService.getNetworkFee(network); - - if (networkFee) { - feeRate = networkFee.high; - } - } - - if (!feeRate.maxFee) { - throw ethErrors.rpc.internal({ - data: { reason: CommonError.UnknownNetworkFee }, - }); - } - - const nonce = await ( - provider as JsonRpcApiProvider - ).getTransactionCount(from); - - const gasLimit = await this.feeService.estimateGasLimit( - from, - to as string, - data as string, - network - ); - - const result = await this.walletService.sign( - { - from, - to, - data, - chainId: sourceChainId, - gasLimit, - maxFeePerGas: feeRate.maxFee, - maxPriorityFeePerGas: feeRate.maxTip, - nonce, - } as TransactionRequest, - network, - tabId - ); - - const hash = await this.networkService.sendTransaction(result, network); - - return hash as `0x${string}`; - }, - }); - - await this.updatePendingTransfer(bridgeTransfer); - this.trackTransfer(bridgeTransfer); - - return bridgeTransfer; - } - trackTransfer(bridgeTransfer: BridgeTransfer) { const { result } = this.#core.trackTransfer({ bridgeTransfer, @@ -417,31 +198,6 @@ export class UnifiedBridgeService implements OnStorageReady { } } - async #buildChain(chainId: number): Promise { - const network = await this.networkService.getNetwork(chainId); - - if (!network) { - throw ethErrors.rpc.invalidParams({ - data: { - reason: CommonError.UnknownNetwork, - }, - }); - } - - return { - chainId: chainIdToCaip(network.chainId), - chainName: network.chainName, - rpcUrl: network.rpcUrl, - networkToken: { - ...network.networkToken, - type: TokenType.NATIVE, - }, - utilityAddresses: { - multicall: network.utilityAddresses?.multicall as `0x${string}`, - }, - }; - } - addListener(eventName: string, callback) { this.#eventEmitter.addListener(eventName, callback); } diff --git a/src/background/services/unifiedBridge/events/eventFilters.ts b/src/background/services/unifiedBridge/events/eventFilters.ts index 88f2db919..e2e5e69c3 100644 --- a/src/background/services/unifiedBridge/events/eventFilters.ts +++ b/src/background/services/unifiedBridge/events/eventFilters.ts @@ -1,6 +1,5 @@ import { ExtensionConnectionEvent } from '@src/background/connections/models'; import { - UnifiedBridgeAssetsUpdated, UnifiedBridgeEvent, UnifiedBridgeStateUpdateEvent, UnifiedBridgeTransferStepChangeEvent, @@ -15,8 +14,3 @@ export const isUnifiedBridgeTransferStepChanged = ( ev: ExtensionConnectionEvent ): ev is UnifiedBridgeTransferStepChangeEvent => ev.name === UnifiedBridgeEvent.TransferStepChange; - -export const isUnifiedBridgeAssetsUpdatedEvent = ( - ev: ExtensionConnectionEvent -): ev is UnifiedBridgeAssetsUpdated => - ev.name === UnifiedBridgeEvent.AssetsUpdated; diff --git a/src/background/services/unifiedBridge/handlers/index.ts b/src/background/services/unifiedBridge/handlers/index.ts index fe5d923ec..dd7e42ab7 100644 --- a/src/background/services/unifiedBridge/handlers/index.ts +++ b/src/background/services/unifiedBridge/handlers/index.ts @@ -1,3 +1 @@ -export * from './unifiedBridgeGetFee'; export * from './unifiedBridgeGetState'; -export * from './unifiedBridgeTransferAsset'; diff --git a/src/background/services/unifiedBridge/handlers/unifiedBridgeEstimateGas.test.ts b/src/background/services/unifiedBridge/handlers/unifiedBridgeEstimateGas.test.ts deleted file mode 100644 index 1bbdd7b6c..000000000 --- a/src/background/services/unifiedBridge/handlers/unifiedBridgeEstimateGas.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; -import { serializeToJSON } from '@src/background/serialization/serialize'; - -import { UnifiedBridgeEstimateGas } from './unifiedBridgeEstimateGas'; -import { buildRpcCall } from '@src/tests/test-utils'; -import { caipToChainId } from '@src/utils/caipConversion'; - -jest.mock('@src/utils/caipConversion'); - -describe('src/background/services/unifiedBridge/handlers/unifiedBridgeEstimateGas', () => { - const unifiedBridgeService = { - estimateGas: jest.fn(), - } as any; - - beforeEach(() => { - jest.resetAllMocks(); - - jest.mocked(caipToChainId).mockReturnValue(43113); - }); - - const asset = {}; - const amount = 100n; - const targetChainId = 5; - const request = { - id: '123', - method: ExtensionRequest.UNIFIED_BRIDGE_ESTIMATE_GAS, - params: [asset, amount, targetChainId], - } as any; - - it('calls .estimateGas() with passed params', async () => { - const handler = new UnifiedBridgeEstimateGas(unifiedBridgeService); - - await handler.handle(buildRpcCall(request)); - - expect(unifiedBridgeService.estimateGas).toHaveBeenCalledWith({ - asset, - amount, - sourceChainId: 43113, - targetChainId, - }); - }); - - it('returns the gas limit estimation', async () => { - const handler = new UnifiedBridgeEstimateGas(unifiedBridgeService); - - unifiedBridgeService.estimateGas.mockResolvedValue(1234n); - - const { result } = await handler.handle(buildRpcCall(request)); - expect(serializeToJSON(result)).toEqual(serializeToJSON(1234n)); - }); -}); diff --git a/src/background/services/unifiedBridge/handlers/unifiedBridgeEstimateGas.ts b/src/background/services/unifiedBridge/handlers/unifiedBridgeEstimateGas.ts deleted file mode 100644 index 4c80e4750..000000000 --- a/src/background/services/unifiedBridge/handlers/unifiedBridgeEstimateGas.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { injectable } from 'tsyringe'; -import { BridgeAsset } from '@avalabs/bridge-unified'; - -import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; -import { ExtensionRequestHandler } from '@src/background/connections/models'; - -import { UnifiedBridgeService } from '../UnifiedBridgeService'; -import { caipToChainId } from '@src/utils/caipConversion'; - -type HandlerType = ExtensionRequestHandler< - ExtensionRequest.UNIFIED_BRIDGE_ESTIMATE_GAS, - bigint, - [asset: BridgeAsset, amount: bigint, targetChainId: number] ->; - -@injectable() -export class UnifiedBridgeEstimateGas implements HandlerType { - method = ExtensionRequest.UNIFIED_BRIDGE_ESTIMATE_GAS as const; - - constructor(private unifiedBridgeService: UnifiedBridgeService) {} - - handle: HandlerType['handle'] = async ({ request, scope }) => { - const [asset, amount, targetChainId] = request.params; - - const gasLimit = await this.unifiedBridgeService.estimateGas({ - asset, - amount, - sourceChainId: caipToChainId(scope), - targetChainId, - }); - - return { - ...request, - result: gasLimit, - }; - }; -} diff --git a/src/background/services/unifiedBridge/handlers/unifiedBridgeGetFee.test.ts b/src/background/services/unifiedBridge/handlers/unifiedBridgeGetFee.test.ts deleted file mode 100644 index 2a2b00b36..000000000 --- a/src/background/services/unifiedBridge/handlers/unifiedBridgeGetFee.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; -import { serializeToJSON } from '@src/background/serialization/serialize'; - -import { UnifiedBridgeGetFee } from './unifiedBridgeGetFee'; -import { buildRpcCall } from '@src/tests/test-utils'; - -describe('src/background/services/unifiedBridge/handlers/unifiedBridgeGetFee', () => { - const unifiedBridgeService = { - getFee: jest.fn(), - } as any; - - beforeEach(() => { - jest.resetAllMocks(); - }); - - const asset = {}; - const amount = 100n; - const sourceChainId = 43114; - const targetChainId = 5; - const request = { - id: '123', - method: ExtensionRequest.UNIFIED_BRIDGE_GET_FEE, - params: [asset, amount, sourceChainId, targetChainId], - } as any; - - it('calls .getFee() with passed params', async () => { - const handler = new UnifiedBridgeGetFee(unifiedBridgeService); - - await handler.handle(buildRpcCall(request, `eip155:${sourceChainId}`)); - - expect(unifiedBridgeService.getFee).toHaveBeenCalledWith({ - asset, - amount, - sourceChainId, - targetChainId, - }); - }); - - it('returns the transfer fee', async () => { - const handler = new UnifiedBridgeGetFee(unifiedBridgeService); - - unifiedBridgeService.getFee.mockResolvedValue(1234n); - - const { result } = await handler.handle(buildRpcCall(request)); - expect(serializeToJSON(result)).toEqual(serializeToJSON(1234n)); - }); -}); diff --git a/src/background/services/unifiedBridge/handlers/unifiedBridgeGetFee.ts b/src/background/services/unifiedBridge/handlers/unifiedBridgeGetFee.ts deleted file mode 100644 index 8e99ec3b9..000000000 --- a/src/background/services/unifiedBridge/handlers/unifiedBridgeGetFee.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { injectable } from 'tsyringe'; -import { BridgeAsset } from '@avalabs/bridge-unified'; - -import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; -import { ExtensionRequestHandler } from '@src/background/connections/models'; - -import { UnifiedBridgeService } from '../UnifiedBridgeService'; - -type HandlerType = ExtensionRequestHandler< - ExtensionRequest.UNIFIED_BRIDGE_GET_FEE, - bigint, - [ - asset: BridgeAsset, - amount: bigint, - sourceChainId: number, - targetChainId: number - ] ->; - -@injectable() -export class UnifiedBridgeGetFee implements HandlerType { - method = ExtensionRequest.UNIFIED_BRIDGE_GET_FEE as const; - - constructor(private unifiedBridgeService: UnifiedBridgeService) {} - - handle: HandlerType['handle'] = async ({ request }) => { - const [asset, amount, sourceChainId, targetChainId] = request.params; - - const transferFee = await this.unifiedBridgeService.getFee({ - asset, - amount, - sourceChainId, - targetChainId, - }); - - return { - ...request, - result: transferFee, - }; - }; -} diff --git a/src/background/services/unifiedBridge/handlers/unifiedBridgeGetAssets.ts b/src/background/services/unifiedBridge/handlers/unifiedBridgeTrackTransfer.ts similarity index 56% rename from src/background/services/unifiedBridge/handlers/unifiedBridgeGetAssets.ts rename to src/background/services/unifiedBridge/handlers/unifiedBridgeTrackTransfer.ts index fe9418717..21bef7975 100644 --- a/src/background/services/unifiedBridge/handlers/unifiedBridgeGetAssets.ts +++ b/src/background/services/unifiedBridge/handlers/unifiedBridgeTrackTransfer.ts @@ -1,27 +1,34 @@ import { injectable } from 'tsyringe'; +import { BridgeTransfer } from '@avalabs/bridge-unified'; import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; import { ExtensionRequestHandler } from '@src/background/connections/models'; import { UnifiedBridgeService } from '../UnifiedBridgeService'; -import { ChainAssetMap } from '@avalabs/bridge-unified'; type HandlerType = ExtensionRequestHandler< - ExtensionRequest.UNIFIED_BRIDGE_GET_ASSETS, - ChainAssetMap + ExtensionRequest.UNIFIED_BRIDGE_TRACK_TRANSFER, + void, + [transfer: BridgeTransfer] >; @injectable() -export class UnifiedBridgeGetAssets implements HandlerType { - method = ExtensionRequest.UNIFIED_BRIDGE_GET_ASSETS as const; +export class UnifiedBridgeTrackTransfer implements HandlerType { + method = ExtensionRequest.UNIFIED_BRIDGE_TRACK_TRANSFER as const; constructor(private unifiedBridgeService: UnifiedBridgeService) {} handle: HandlerType['handle'] = async ({ request }) => { + const [transfer] = request.params; + try { + await this.unifiedBridgeService.updatePendingTransfer(transfer); + + this.unifiedBridgeService.trackTransfer(transfer); + return { ...request, - result: await this.unifiedBridgeService.getAssets(), + result: undefined, }; } catch (ex: any) { return { diff --git a/src/background/services/unifiedBridge/handlers/unifiedBridgeTransferAsset.test.ts b/src/background/services/unifiedBridge/handlers/unifiedBridgeTransferAsset.test.ts deleted file mode 100644 index 9b8ac5c3f..000000000 --- a/src/background/services/unifiedBridge/handlers/unifiedBridgeTransferAsset.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; - -import { UnifiedBridgeTransferAsset } from './unifiedBridgeTransferAsset'; -import { buildRpcCall } from '@src/tests/test-utils'; - -describe('src/background/services/unifiedBridge/handlers/unifiedBridgeTransferAsset', () => { - const unifiedBridgeService = { - transfer: jest.fn(), - } as any; - - beforeEach(() => { - jest.resetAllMocks(); - }); - - const asset = {}; - const amount = 100n; - const targetChainId = 5; - const request = { - id: '123', - method: ExtensionRequest.UNIFIED_BRIDGE_GET_FEE, - params: [asset, amount, targetChainId], - tabId: 1234, - } as any; - - it('calls .transfer() with proper params', async () => { - const handler = new UnifiedBridgeTransferAsset(unifiedBridgeService); - - await handler.handle(buildRpcCall(request, 'eip155:1')); - - expect(unifiedBridgeService.transfer).toHaveBeenCalledWith({ - asset, - amount, - sourceChainId: 1, - targetChainId, - tabId: request.tabId, // include the tabId - }); - }); - - it('returns hash of the source chain transfer transaction', async () => { - jest.mocked(unifiedBridgeService.transfer).mockResolvedValue({ - sourceTxHash: 'sourceTxHash', - }); - - const handler = new UnifiedBridgeTransferAsset(unifiedBridgeService); - - const { result } = await handler.handle(buildRpcCall(request, 'eip155:1')); - - expect(result).toEqual('sourceTxHash'); - }); -}); diff --git a/src/background/services/unifiedBridge/handlers/unifiedBridgeTransferAsset.ts b/src/background/services/unifiedBridge/handlers/unifiedBridgeTransferAsset.ts deleted file mode 100644 index 88a6158cf..000000000 --- a/src/background/services/unifiedBridge/handlers/unifiedBridgeTransferAsset.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { injectable } from 'tsyringe'; -import { BridgeAsset } from '@avalabs/bridge-unified'; - -import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; -import { ExtensionRequestHandler } from '@src/background/connections/models'; - -import { UnifiedBridgeService } from '../UnifiedBridgeService'; -import { CustomGasSettings } from '../../bridge/models'; -import { caipToChainId } from '@src/utils/caipConversion'; - -type HandlerType = ExtensionRequestHandler< - ExtensionRequest.UNIFIED_BRIDGE_TRANSFER_ASSET, - any, - [ - asset: BridgeAsset, - amount: bigint, - targetChainId: number, - customGasSettings?: CustomGasSettings - ] ->; - -@injectable() -export class UnifiedBridgeTransferAsset implements HandlerType { - method = ExtensionRequest.UNIFIED_BRIDGE_TRANSFER_ASSET as const; - - constructor(private unifiedBridgeService: UnifiedBridgeService) {} - - handle: HandlerType['handle'] = async ({ request, scope }) => { - const [asset, amount, targetChainId, customGasSettings] = request.params; - - try { - const bridgeTransfer = await this.unifiedBridgeService.transfer({ - asset, - amount, - sourceChainId: caipToChainId(scope), - targetChainId, - customGasSettings, - tabId: request.tabId, - }); - - return { - ...request, - result: bridgeTransfer.sourceTxHash, - }; - } catch (ex: any) { - return { - ...request, - error: ex, - }; - } - }; -} diff --git a/src/background/services/unifiedBridge/models.ts b/src/background/services/unifiedBridge/models.ts index 937e1ba12..0861cd010 100644 --- a/src/background/services/unifiedBridge/models.ts +++ b/src/background/services/unifiedBridge/models.ts @@ -5,13 +5,13 @@ import { ChainAssetMap, } from '@avalabs/bridge-unified'; import { FeatureGates } from '../featureFlags/models'; -import { CustomGasSettings } from '../bridge/models'; export enum UnifiedBridgeError { UnknownAsset = 'unknown-asset', AmountLessThanFee = 'amount-less-than-fee', InvalidFee = 'invalid-fee', UnsupportedNetwork = 'unsupported-network', + InvalidTxPayload = 'invalid-tx-payload', } export type UnifiedBridgeState = { @@ -54,7 +54,6 @@ export type UnifiedBridgeTransferParams = { amount: bigint; targetChainId: number; sourceChainId: number; - customGasSettings?: CustomGasSettings; tabId?: number; }; diff --git a/src/background/services/wallet/handlers/bitcoin_sendTransaction.ts b/src/background/services/wallet/handlers/bitcoin_sendTransaction.ts index a57f58428..c675b42f6 100644 --- a/src/background/services/wallet/handlers/bitcoin_sendTransaction.ts +++ b/src/background/services/wallet/handlers/bitcoin_sendTransaction.ts @@ -9,7 +9,10 @@ import { Action } from '../../actions/models'; import { DEFERRED_RESPONSE } from '@src/background/connections/middlewares/models'; import { NetworkService } from '@src/background/services/network/NetworkService'; import { ethErrors } from 'eth-rpc-errors'; -import { DisplayData_BitcoinSendTx } from '@src/background/services/wallet/handlers/models'; +import { + DisplayData_BitcoinSendTx, + TxDisplayOptions, +} from '@src/background/services/wallet/handlers/models'; import { AccountsService } from '@src/background/services/accounts/AccountsService'; import { ChainId } from '@avalabs/core-chains-sdk'; import { BalanceAggregatorService } from '@src/background/services/balances/BalanceAggregatorService'; @@ -29,8 +32,14 @@ import { SendErrorMessage } from '@src/utils/send/models'; import { resolve } from '@avalabs/core-utils-sdk'; import { openApprovalWindow } from '@src/background/runtime/openApprovalWindow'; +import { runtime } from 'webextension-polyfill'; -type BitcoinTxParams = [address: string, amount: string, feeRate: number]; +type BitcoinTxParams = [ + address: string, + amount: string, + feeRate: number, + displayOptions?: TxDisplayOptions +]; @injectable() export class BitcoinSendTransactionHandler extends DAppRequestHandler< @@ -126,13 +135,23 @@ export class BitcoinSendTransactionHandler extends DAppRequestHandler< }; } - const [address, amount, feeRate] = (request.params ?? + const [address, amount, feeRate, displayOptions] = (request.params ?? []) as BitcoinTxParams; const isMainnet = this.networkService.isMainnet(); const token = await this.#getBalance( this.accountService.activeAccount as EnsureDefined ); + // Only the extension UI is allowed to suggest custom display options + if (displayOptions && request.site?.domain !== runtime.id) { + return { + ...request, + error: ethErrors.rpc.invalidRequest({ + message: 'Unauthorized use of display options', + }), + }; + } + if (!token) { return { ...request, @@ -197,6 +216,7 @@ export class BitcoinSendTransactionHandler extends DAppRequestHandler< sendFee, feeRate, balance: token, + displayOptions, }; const actionData = { diff --git a/src/background/services/wallet/handlers/eth_sendTransaction/eth_sendTransaction.ts b/src/background/services/wallet/handlers/eth_sendTransaction/eth_sendTransaction.ts index 7ef1011e0..73eaa23eb 100644 --- a/src/background/services/wallet/handlers/eth_sendTransaction/eth_sendTransaction.ts +++ b/src/background/services/wallet/handlers/eth_sendTransaction/eth_sendTransaction.ts @@ -10,7 +10,12 @@ import { Action } from '@src/background/services/actions/models'; import { NetworkService } from '@src/background/services/network/NetworkService'; import getTargetNetworkForTx from './utils/getTargetNetworkForTx'; import { FeatureFlagService } from '@src/background/services/featureFlags/FeatureFlagService'; -import { JsonRpcApiProvider, Result, TransactionDescription } from 'ethers'; +import { + ContractTransaction, + JsonRpcApiProvider, + Result, + TransactionDescription, +} from 'ethers'; import { EthSendTransactionParams, EthSendTransactionParamsWithGas, @@ -27,7 +32,7 @@ import { getTxDescription } from './utils/getTxDescription'; import { ContractParserHandler } from './contracts/contractParsers/models'; import { contractParserMap } from './contracts/contractParsers/contractParserMap'; import { parseBasicDisplayValues } from './contracts/contractParsers/utils/parseBasicDisplayValues'; -import browser from 'webextension-polyfill'; +import browser, { runtime } from 'webextension-polyfill'; import { getExplorerAddressByNetwork } from '@src/utils/getExplorerAddress'; import { txToCustomEvmTx } from './utils/txToCustomEvmTx'; import { Network } from '@avalabs/core-chains-sdk'; @@ -37,12 +42,16 @@ import { AnalyticsServicePosthog } from '@src/background/services/analytics/Anal import { getProviderForNetwork } from '@src/utils/network/getProviderForNetwork'; import { BlockaidService } from '@src/background/services/blockaid/BlockaidService'; import { openApprovalWindow } from '@src/background/runtime/openApprovalWindow'; -import { caipToChainId } from '@src/utils/caipConversion'; import { EnsureDefined } from '@src/background/models'; +import { caipToChainId } from '@src/utils/caipConversion'; +import { TxDisplayOptions } from '../models'; + +type TxPayload = EthSendTransactionParams | ContractTransaction; +type Params = [TxPayload] | [TxPayload, TxDisplayOptions]; @injectable() export class EthSendTransactionHandler extends DAppRequestHandler< - [EthSendTransactionParams], + Params, string > { methods = [DAppProviderRequest.ETH_SEND_TX]; @@ -71,10 +80,17 @@ export class EthSendTransactionHandler extends DAppRequestHandler< handleAuthenticated = async ({ request, scope, - }: JsonRpcRequestParams) => { + }: JsonRpcRequestParams) => { const { params, site } = request; const rawParams = (params || [])[0] as EthSendTransactionParams; + const displayOptions = params[1]; + + // Only the extension UI is allowed to suggest custom display options + if (displayOptions && site?.domain !== runtime.id) { + throw new Error('Unauthorized use of display options'); + } + const trxParams = { ...rawParams, chainId: rawParams.chainId ?? `0x${caipToChainId(scope).toString(16)}`, @@ -174,6 +190,7 @@ export class EthSendTransactionHandler extends DAppRequestHandler< chainId: network.chainId.toString(), txParams: txPayload, displayValues, + displayOptions, }, }; diff --git a/src/background/services/wallet/handlers/eth_sendTransaction/models.ts b/src/background/services/wallet/handlers/eth_sendTransaction/models.ts index 7cb7a91dd..5d5304cb5 100644 --- a/src/background/services/wallet/handlers/eth_sendTransaction/models.ts +++ b/src/background/services/wallet/handlers/eth_sendTransaction/models.ts @@ -1,5 +1,6 @@ import { TokenType } from '@src/background/services/balances/models'; import { DomainMetadata, EnsureDefined } from '@src/background/models'; +import { TxDisplayOptions } from '../models'; export enum AvalancheChainStrings { AVM = 'X Chain', @@ -190,12 +191,13 @@ export interface Transaction { chainId: string; txParams: EnsureDefined; displayValues: TransactionDisplayValues; + displayOptions?: TxDisplayOptions; } export interface EthSendTransactionParams { from: string; to?: string; - value?: string; + value?: string | bigint; data?: string; gas?: number; gasPrice?: string; diff --git a/src/background/services/wallet/handlers/eth_sendTransaction/utils/getTxInfo.ts b/src/background/services/wallet/handlers/eth_sendTransaction/utils/getTxInfo.ts index 82b47bda0..680901fda 100644 --- a/src/background/services/wallet/handlers/eth_sendTransaction/utils/getTxInfo.ts +++ b/src/background/services/wallet/handlers/eth_sendTransaction/utils/getTxInfo.ts @@ -1,4 +1,4 @@ -import { Interface, TransactionDescription } from 'ethers'; +import { Interface, TransactionDescription, toBeHex } from 'ethers'; import { getABIForContract, getSourceForContract, @@ -51,9 +51,10 @@ async function getAvalancheABIFromSource(address: string, isMainnet: boolean) { export async function getTxInfo( address: string, data: string, - value: string, + value: string | bigint, network: Network ): Promise { + const hexValue = toBeHex(value); /** * We already eliminate BTC as a tx requestor so we only need to verify if we are still on a * avalanche net. At this point anything else would be a subnet @@ -62,7 +63,7 @@ export async function getTxInfo( network?.chainId !== ChainId.AVALANCHE_TESTNET_ID && network?.chainId !== ChainId.AVALANCHE_MAINNET_ID ) { - return parseDataWithABI(data, value, new Interface(ERC20.abi)); + return parseDataWithABI(data, hexValue, new Interface(ERC20.abi)); } const { result, contractSource } = await getAvalancheABIFromSource( @@ -78,5 +79,5 @@ export async function getTxInfo( if (!abi) { throw new Error('unable to get abi'); } - return parseDataWithABI(data, value, new Interface(abi)); + return parseDataWithABI(data, hexValue, new Interface(abi)); } diff --git a/src/background/services/wallet/handlers/models.ts b/src/background/services/wallet/handlers/models.ts index 423db65d5..70c759cd1 100644 --- a/src/background/services/wallet/handlers/models.ts +++ b/src/background/services/wallet/handlers/models.ts @@ -9,6 +9,7 @@ export interface DisplayData_BitcoinSendTx { sendFee: number; // satoshis feeRate: number; balance: TokenWithBalanceBTC; + displayOptions?: TxDisplayOptions; } export type ImportSeedphraseWalletParams = { @@ -33,3 +34,11 @@ export type ImportWalletResult = { export enum SeedphraseImportError { ExistingSeedphrase = 'existing-seedphrase', } + +export type TxDisplayOptions = { + customApprovalScreenTitle?: string; + contextInformation?: { + title: string; + notice?: string; + }; +}; diff --git a/src/components/common/BNInput.tsx b/src/components/common/BNInput.tsx index 5f861fc95..006de1405 100644 --- a/src/components/common/BNInput.tsx +++ b/src/components/common/BNInput.tsx @@ -14,6 +14,8 @@ import { bnToLocaleString, numberToBN } from '@avalabs/core-utils-sdk'; Big.PE = 99; Big.NE = -18; +const BN_ZERO = new BN(0); + export interface BNInputProps { value?: BN; denomination: number; @@ -118,6 +120,8 @@ export function BNInput({ onValueChanged(big.toString()); }; + const isMaxBtnVisible = max?.gt(BN_ZERO); + return ( - ) : max ? ( + ) : isMaxBtnVisible ? ( - - - - {/* To section */} - - - - - {t('To')} - - - {targetNetwork ? targetNetwork.chainName : ''} - - - } sx={{ rowGap: 2 }} /> - - - {t('Receive')} - - {formattedReceiveAmount} - - - - {t('Estimated')} - - - - {formattedReceiveAmountCurrency} - - - - - - - - - - {wrapStatus === WrapStatus.WAITING_FOR_DEPOSIT && ( - - {t('Waiting for deposit confirmation')} - - )} - - - { - onGasSettingsChanged({ - maxFeePerGas: settings.maxFeePerGas, - maxPriorityFeePerGas: settings.maxPriorityFeePerGas, - // do not allow changing gasLimit via the UI - }); - - if (settings.feeType) { - setSelectedGasFee(settings.feeType); - } - }} - onModifierChangeCallback={( - modifier: GasFeeModifier | undefined - ) => { - if (modifier) { - setSelectedGasFee(modifier); - } - capture('BridgeFeeOptionChanged', { modifier }); - }} - selectedGasFeeModifier={selectedGasFee} - network={network} - networkFee={networkFee} - /> - - - - - - {/* FIXME: Unified SDK can handle multiple bridges, but for now it's just the CCTP */} - {provider === BridgeProviders.Unified && ( - - {t('Powered by')} - - Circle - - ), - }} - /> - } - > - - - - )} - - - - - + ) : ( + + )} + ); } diff --git a/src/pages/Bridge/BridgeConfirmation.tsx b/src/pages/Bridge/BridgeConfirmation.tsx deleted file mode 100644 index 72cff1438..000000000 --- a/src/pages/Bridge/BridgeConfirmation.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import { - Button, - Card, - ChevronLeftIcon, - Divider, - Stack, - Typography, -} from '@avalabs/core-k2-components'; -import { PageTitle } from '@src/components/common/PageTitle'; -import { useTranslation } from 'react-i18next'; -import { useHistory } from 'react-router-dom'; -import { NetworkLogo } from '@src/components/common/NetworkLogo'; -import { useSyncBridgeConfig } from './hooks/useSyncBridgeConfig'; -import { useSetBridgeChainFromNetwork } from './hooks/useSetBridgeChainFromNetwork'; -import Big from 'big.js'; -import { bigToLocaleString } from '@avalabs/core-utils-sdk'; - -type NetworkInfo = { - logoUri?: string; - name?: string; -}; - -type BridgeConfirmProps = { - tokenAmount: string; - currencyAmount: string; - source: NetworkInfo; - target: NetworkInfo; - currentAsset?: string; - bridgeFee?: string; - receiveAmount?: Big; - receiveAmountCurrency?: string; - onSubmit: () => void; -}; - -export function BridgeConfirmation({ - tokenAmount, - currencyAmount, - source, - target, - currentAsset, - bridgeFee, - receiveAmount, - receiveAmountCurrency, - onSubmit, -}: BridgeConfirmProps) { - useSyncBridgeConfig(); // keep bridge config up-to-date - useSetBridgeChainFromNetwork(); - - const history = useHistory(); - const { t } = useTranslation(); - - return ( - - - - history.goBack()} - size={30} - sx={{ cursor: 'pointer', mr: -1 }} - /> - - {t('Confirm Transaction')} - - - - - - - - - {t('Bridging Amount')} - - - {tokenAmount} - - {currentAsset} - - - - - - - {currencyAmount} - - - - - - } sx={{ rowGap: 2 }}> - - - {t('From')} - - - {source.name} - - - - - - - {t('To')} - - - {target.name} - - - - - - - - - - - {t('Bridging Fee')} - - - - {bridgeFee} - - - - - - - {t('Receive')} - - - - {receiveAmount ? bigToLocaleString(receiveAmount, 9) : '-'} - - - {currentAsset} - - - - - - {receiveAmountCurrency?.replace('~', '')} - - - - - - - - - - - - ); -} -export default BridgeConfirmation; diff --git a/src/pages/Bridge/components/BridgeForm.tsx b/src/pages/Bridge/components/BridgeForm.tsx new file mode 100644 index 000000000..b6c272387 --- /dev/null +++ b/src/pages/Bridge/components/BridgeForm.tsx @@ -0,0 +1,706 @@ +import { + AlertCircleIcon, + Button, + Card, + Divider, + InfoCircleIcon, + Link, + Scrollbars, + Stack, + SwapIcon, + Tooltip, + Typography, + useTheme, +} from '@avalabs/core-k2-components'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { + Asset, + BIG_ZERO, + Blockchain, + WrapStatus, + formatTokenAmount, + useBridgeSDK, + useGetTokenSymbolOnNetwork, +} from '@avalabs/core-bridge-sdk'; +import { bigToBN, bigToLocaleString, bnToBig } from '@avalabs/core-utils-sdk'; +import Big from 'big.js'; +import BN from 'bn.js'; + +import { TokenSelect } from '@src/components/common/TokenSelect'; +import { + TokenType, + TokenWithBalance, +} from '@src/background/services/balances/models'; +import { useSettingsContext } from '@src/contexts/SettingsProvider'; +import { Network } from '@src/background/services/network/models'; +import { useSendAnalyticsData } from '@src/hooks/useSendAnalyticsData'; +import { NavigationHistoryDataState } from '@src/background/services/navigationHistory/models'; +import { useAnalyticsContext } from '@src/contexts/AnalyticsProvider'; +import { useUnifiedBridgeContext } from '@src/contexts/UnifiedBridgeProvider'; +import { useNetworkContext } from '@src/contexts/NetworkProvider'; + +import { AssetBalance } from '../models'; +import { BridgeProviders } from '../hooks/useBridge'; +import { getTokenAddress } from '../utils/getTokenAddress'; +import { blockchainToNetwork } from '../utils/blockchainConversion'; +import { isUnifiedBridgeAsset } from '../utils/isUnifiedBridgeAsset'; + +import { NetworkSelector } from './NetworkSelector'; +import { useHasEnoughForGas } from '../hooks/useHasEnoughtForGas'; + +function formatBalance(balance: Big | undefined) { + return balance ? formatTokenAmount(balance, 6) : '-'; +} + +export type BridgeFormProps = { + // VM-specific props + minimum?: Big; + maximum?: Big; + receiveAmount?: Big; + sourceBalance?: AssetBalance; + assetsWithBalances?: AssetBalance[]; + price?: number; + loading?: boolean; + wrapStatus?: WrapStatus; + estimateGas(amount: Big, asset?: Asset): Promise; + + isPending: boolean; + + // Generic props + currentAssetAddress?: string; + provider: BridgeProviders; + amount: Big; + isAmountTooLow: boolean; + availableBlockchains: Blockchain[]; + targetNetwork?: Network; + bridgeError: string; + setBridgeError: (err: string) => void; + setCurrentAssetAddress: (assetAddress?: string) => void; + setNavigationHistoryData: (data: NavigationHistoryDataState) => void; + setAmount: (amount: Big) => void; + onTransfer: () => void; + handleBlockchainChange: (blockchain: Blockchain) => void; +}; + +export const BridgeForm = ({ + currentAssetAddress, + provider, + amount, + isAmountTooLow, + availableBlockchains, + minimum, + maximum, + receiveAmount, + sourceBalance, + assetsWithBalances, + targetNetwork, + price, + loading, + estimateGas, + bridgeError, + setBridgeError, + setCurrentAssetAddress, + setNavigationHistoryData, + setAmount, + onTransfer, + handleBlockchainChange, + isPending, +}: BridgeFormProps) => { + const { + bridgeConfig, + currentAsset, + currentAssetData, + setCurrentAsset, + currentBlockchain, + targetBlockchain, + } = useBridgeSDK(); + + const { t } = useTranslation(); + const theme = useTheme(); + const cardRef = useRef(null); + + const { setNetwork, networks } = useNetworkContext(); + const { currencyFormatter, currency } = useSettingsContext(); + const { getAssetAddressOnTargetChain } = useUnifiedBridgeContext(); + const { getTokenSymbolOnNetwork } = useGetTokenSymbolOnNetwork(); + const { sendTokenSelectedAnalytics, sendAmountEnteredAnalytics } = + useSendAnalyticsData(); + + const { capture } = useAnalyticsContext(); + + const [isTokenSelectOpen, setIsTokenSelectOpen] = useState(false); + + const hasValidAmount = !isAmountTooLow && amount.gt(BIG_ZERO); + + const denomination = useMemo(() => { + if (!sourceBalance) { + return 0; + } + + if (isUnifiedBridgeAsset(sourceBalance.asset)) { + return sourceBalance?.asset.decimals; + } + + return sourceBalance.asset.denomination; + }, [sourceBalance]); + + const amountBN = useMemo( + () => bigToBN(amount, denomination), + [amount, denomination] + ); + + const selectedTokenForTokenSelect: TokenWithBalance | null = useMemo(() => { + if (!currentAsset || !sourceBalance) { + return null; + } + return { + type: TokenType.ERC20, + balanceDisplayValue: formatBalance(sourceBalance.balance), + balance: bigToBN(sourceBalance.balance || BIG_ZERO, denomination), + decimals: denomination, + priceUSD: price, + logoUri: sourceBalance.logoUri, + name: isUnifiedBridgeAsset(sourceBalance.asset) + ? sourceBalance.asset.symbol + : getTokenSymbolOnNetwork( + sourceBalance.asset.symbol, + currentBlockchain + ), + symbol: isUnifiedBridgeAsset(sourceBalance.asset) + ? sourceBalance.asset.symbol + : getTokenSymbolOnNetwork( + sourceBalance.asset.symbol, + currentBlockchain + ), + address: sourceBalance.asset.symbol, + contractType: 'ERC-20', + unconfirmedBalanceDisplayValue: formatBalance( + sourceBalance.unconfirmedBalance + ), + unconfirmedBalance: bigToBN( + sourceBalance.unconfirmedBalance || BIG_ZERO, + denomination + ), + }; + }, [ + currentAsset, + currentBlockchain, + denomination, + getTokenSymbolOnNetwork, + price, + sourceBalance, + ]); + + const gasToken = useMemo( + () => + currentBlockchain === Blockchain.AVALANCHE + ? 'AVAX' + : currentBlockchain === Blockchain.BITCOIN + ? 'BTC' + : 'ETH', + [currentBlockchain] + ); + + const [neededGas, setNeededGas] = useState(0n); + + useEffect(() => { + let isMounted = true; + + if (amount && amount.gt(BIG_ZERO)) { + estimateGas(amount, currentAssetData).then((limit) => { + if (isMounted && typeof limit === 'bigint') { + setNeededGas(limit); + } + }); + + return () => { + isMounted = false; + }; + } + }, [estimateGas, amount, currentAssetData]); + + const hasEnoughForNetworkFee = useHasEnoughForGas(neededGas); + + const errorTooltipContent = useMemo(() => { + return ( + <> + {!hasEnoughForNetworkFee && ( + + + + )} + {isAmountTooLow && ( + + {t(`Amount too low -- minimum is {{minimum}}`, { + minimum: minimum?.toFixed(9) ?? 0, + })} + + )} + {bridgeError && ( + {bridgeError} + )} + + ); + }, [ + bridgeError, + gasToken, + hasEnoughForNetworkFee, + isAmountTooLow, + minimum, + t, + ]); + + const formatCurrency = useCallback( + (targetAmount?: number) => { + return targetAmount + ? `${currencyFormatter(targetAmount).replace(currency, '')} ${currency}` + : '-'; + }, + [currency, currencyFormatter] + ); + + const formattedReceiveAmount = useMemo(() => { + const unit = currentAsset ? ` ${currentAsset}` : ''; + return hasValidAmount && receiveAmount + ? `${bigToLocaleString(receiveAmount, denomination)}${unit}` + : '-'; + }, [currentAsset, hasValidAmount, receiveAmount, denomination]); + + const formattedReceiveAmountCurrency = useMemo(() => { + const result = + hasValidAmount && price && receiveAmount + ? `~${formatCurrency(price * receiveAmount.toNumber())}` + : '-'; + + return result; + }, [formatCurrency, hasValidAmount, price, receiveAmount]); + + const handleAmountChanged = useCallback( + (value: { bn: BN; amount: string }) => { + const bigValue = bnToBig(value.bn, denomination); + setNavigationHistoryData({ + selectedTokenAddress: currentAssetAddress, + selectedToken: currentAsset, + inputAmount: bigValue, + }); + + setAmount(bigValue); + sendAmountEnteredAnalytics('Bridge'); + + // When there is no balance for given token, maximum is undefined + if (!maximum || (maximum && bigValue && maximum.lt(bigValue))) { + const errorMessage = t('Insufficient balance'); + + if (errorMessage === bridgeError) { + return; + } + + setBridgeError(errorMessage); + capture('BridgeTokenSelectError', { + errorMessage, + }); + return; + } + setBridgeError(''); + }, + [ + bridgeError, + capture, + currentAsset, + currentAssetAddress, + setBridgeError, + denomination, + maximum, + sendAmountEnteredAnalytics, + setAmount, + setNavigationHistoryData, + t, + ] + ); + + const handleSelect = useCallback( + (token: AssetBalance) => { + const symbol = token.symbol; + const address = getTokenAddress(token); + + setCurrentAssetAddress(address); + setNavigationHistoryData({ + selectedToken: symbol, + selectedTokenAddress: address, + inputAmount: undefined, + }); + setAmount(BIG_ZERO); + setCurrentAsset(symbol); + sendTokenSelectedAnalytics('Bridge'); + + if (!hasEnoughForNetworkFee) { + capture('BridgeTokenSelectError', { + errorMessage: 'Insufficent balance to cover gas costs.', + }); + } + }, + [ + capture, + hasEnoughForNetworkFee, + sendTokenSelectedAnalytics, + setAmount, + setCurrentAsset, + setCurrentAssetAddress, + setNavigationHistoryData, + ] + ); + + const handleBlockchainSwap = useCallback(() => { + if (targetBlockchain) { + // convert blockChain to Network + const blockChainNetwork = blockchainToNetwork( + targetBlockchain, + networks, + bridgeConfig + ); + + if (blockChainNetwork) { + const assetAddressOnOppositeChain = getAssetAddressOnTargetChain( + currentAsset, + blockChainNetwork.chainId + ); + + setCurrentAssetAddress(assetAddressOnOppositeChain); + setNavigationHistoryData({ + selectedTokenAddress: assetAddressOnOppositeChain, + selectedToken: currentAsset, + inputAmount: undefined, + }); + setAmount(BIG_ZERO); + setNetwork(blockChainNetwork); + setBridgeError(''); + } + } + }, [ + bridgeConfig, + getAssetAddressOnTargetChain, + networks, + setNetwork, + setBridgeError, + setCurrentAssetAddress, + currentAsset, + setAmount, + setNavigationHistoryData, + targetBlockchain, + ]); + + const disableTransfer = useMemo( + () => + bridgeError.length > 0 || + loading || + isPending || + isAmountTooLow || + BIG_ZERO.eq(amount) || + !hasEnoughForNetworkFee, + [ + amount, + bridgeError.length, + hasEnoughForNetworkFee, + isAmountTooLow, + isPending, + loading, + ] + ); + + return ( + <> + + + + + + {/* From section */} + + + + + {t('From')} + + + + + { + setIsTokenSelectOpen(!isTokenSelectOpen); + }} + isOpen={isTokenSelectOpen} + isValueLoading={loading} + setIsOpen={setIsTokenSelectOpen} + padding="0 16px 8px" + skipHandleMaxAmount + label="" + containerRef={cardRef} + /> + + + {(bridgeError || + isAmountTooLow || + !hasEnoughForNetworkFee) && ( + + {errorTooltipContent} + + } + > + + + {t('Error')} + + + + + )} + + + + + {/* Switch to swap from and to */} + + {t('Switch')}}> + + + + + {/* To section */} + + + + + {t('To')} + + + {targetNetwork ? targetNetwork.chainName : ''} + + + } sx={{ rowGap: 2 }} /> + + + {t('Receive')} + + {formattedReceiveAmount} + + + + {t('Estimated')} + + + + {formattedReceiveAmountCurrency} + + + + + + + + + + + + + {/* FIXME: Unified SDK can handle multiple bridges, but for now it's just the CCTP */} + {provider === BridgeProviders.Unified && ( + + {t('Powered by')} + + Circle + + ), + }} + /> + } + > + + + + )} + + + + ); +}; diff --git a/src/pages/Bridge/components/BridgeFormAVAX.tsx b/src/pages/Bridge/components/BridgeFormAVAX.tsx new file mode 100644 index 000000000..1add4b6ca --- /dev/null +++ b/src/pages/Bridge/components/BridgeFormAVAX.tsx @@ -0,0 +1,53 @@ +import Big from 'big.js'; + +import { useAvalancheBridge } from '../hooks/useAvalancheBridge'; + +import { BridgeForm, BridgeFormProps } from './BridgeForm'; +import { useBridgeTxHandling } from '../hooks/useBridgeTxHandling'; + +type BridgeFormAVAXProps = Omit< + BridgeFormProps, + keyof ReturnType | 'onTransfer' | 'isPending' +> & { + amount: Big; + bridgeFee: Big; + minimum: Big; + + onInitiated: () => void; + onSuccess: (txHash: string) => void; + onFailure: (error: unknown) => void; + onRejected: () => void; +}; + +export const BridgeFormAVAX = ({ + onInitiated, + onSuccess, + onFailure, + onRejected, + ...props +}: BridgeFormAVAXProps) => { + const { amount, bridgeFee, minimum } = props; + + const { transfer, ...bridge } = useAvalancheBridge( + amount, + bridgeFee, + minimum + ); + + const { onTransfer, isPending } = useBridgeTxHandling({ + transfer, + onInitiated, + onSuccess, + onFailure, + onRejected, + }); + + return ( + + ); +}; diff --git a/src/pages/Bridge/components/BridgeFormBTC.tsx b/src/pages/Bridge/components/BridgeFormBTC.tsx new file mode 100644 index 000000000..ea6c4333a --- /dev/null +++ b/src/pages/Bridge/components/BridgeFormBTC.tsx @@ -0,0 +1,50 @@ +import Big from 'big.js'; + +import { useBtcBridge } from '../hooks/useBtcBridge'; +import { useBridgeTxHandling } from '../hooks/useBridgeTxHandling'; + +import { BridgeForm, BridgeFormProps } from './BridgeForm'; +import { memo } from 'react'; + +type BridgeFormBTCProps = Omit< + BridgeFormProps, + keyof ReturnType | 'onTransfer' | 'isPending' +> & { + amount: Big; + bridgeFee: Big; + minimum: Big; + + onInitiated: () => void; + onSuccess: (txHash: string) => void; + onFailure: (error: unknown) => void; + onRejected: () => void; +}; + +export const BridgeFormBTC = memo(function BridgeFormBTC({ + onInitiated, + onSuccess, + onFailure, + onRejected, + ...props +}: BridgeFormBTCProps) { + const { amount } = props; + + const { transfer, ...bridge } = useBtcBridge(amount); + + const { onTransfer, isPending } = useBridgeTxHandling({ + transfer, + onInitiated, + onSuccess, + onFailure, + onRejected, + }); + + return ( + + ); +}); diff --git a/src/pages/Bridge/components/BridgeFormETH.tsx b/src/pages/Bridge/components/BridgeFormETH.tsx new file mode 100644 index 000000000..5132aa94f --- /dev/null +++ b/src/pages/Bridge/components/BridgeFormETH.tsx @@ -0,0 +1,49 @@ +import type Big from 'big.js'; + +import { useEthBridge } from '../hooks/useEthBridge'; +import { useBridgeTxHandling } from '../hooks/useBridgeTxHandling'; + +import { BridgeForm, BridgeFormProps } from './BridgeForm'; + +type BridgeFormETHProps = Omit< + BridgeFormProps, + keyof ReturnType | 'onTransfer' | 'isPending' +> & { + amount: Big; + bridgeFee: Big; + minimum: Big; + + onInitiated: () => void; + onSuccess: (txHash: string) => void; + onFailure: (error: unknown) => void; + onRejected: () => void; +}; + +export const BridgeFormETH = ({ + onInitiated, + onSuccess, + onFailure, + onRejected, + ...props +}: BridgeFormETHProps) => { + const { amount, bridgeFee, minimum } = props; + + const { transfer, ...bridge } = useEthBridge(amount, bridgeFee, minimum); + + const { onTransfer, isPending } = useBridgeTxHandling({ + transfer, + onInitiated, + onSuccess, + onFailure, + onRejected, + }); + + return ( + + ); +}; diff --git a/src/pages/Bridge/components/BridgeFormUnified.tsx b/src/pages/Bridge/components/BridgeFormUnified.tsx new file mode 100644 index 000000000..1403612c1 --- /dev/null +++ b/src/pages/Bridge/components/BridgeFormUnified.tsx @@ -0,0 +1,53 @@ +import type Big from 'big.js'; + +import { useUnifiedBridge } from '../hooks/useUnifiedBridge'; +import { useBridgeTxHandling } from '../hooks/useBridgeTxHandling'; + +import { BridgeForm, BridgeFormProps } from './BridgeForm'; + +type BridgeFormUnifiedProps = Omit< + BridgeFormProps, + keyof ReturnType | 'onTransfer' | 'isPending' +> & { + amount: Big; + targetChainId: number; + currentAssetAddress?: string; + + onInitiated: () => void; + onSuccess: (txHash: string) => void; + onFailure: (error: unknown) => void; + onRejected: () => void; +}; + +export const BridgeFormUnified = ({ + onInitiated, + onSuccess, + onFailure, + onRejected, + ...props +}: BridgeFormUnifiedProps) => { + const { amount, targetChainId, currentAssetAddress } = props; + + const { transfer, ...bridge } = useUnifiedBridge( + amount, + targetChainId, + currentAssetAddress + ); + + const { onTransfer, isPending } = useBridgeTxHandling({ + transfer, + onInitiated, + onSuccess, + onFailure, + onRejected, + }); + + return ( + + ); +}; diff --git a/src/pages/Bridge/components/BridgeUnknownNetwork.tsx b/src/pages/Bridge/components/BridgeUnknownNetwork.tsx index 58f88b465..0af6c3231 100644 --- a/src/pages/Bridge/components/BridgeUnknownNetwork.tsx +++ b/src/pages/Bridge/components/BridgeUnknownNetwork.tsx @@ -27,7 +27,7 @@ export const BridgeUnknownNetwork = ({ onSelect }) => { mx: 2, }} > - {t(' Network not supported.')} + {t('Network not supported.')} {t( 'Network is not supported. Change network to supported network to continue.' diff --git a/src/pages/Bridge/components/NetworkSelector.tsx b/src/pages/Bridge/components/NetworkSelector.tsx index 02ea5260d..046a94409 100644 --- a/src/pages/Bridge/components/NetworkSelector.tsx +++ b/src/pages/Bridge/components/NetworkSelector.tsx @@ -1,20 +1,19 @@ import { Blockchain } from '@avalabs/core-bridge-sdk'; import { AvaxTokenIcon } from '@src/components/icons/AvaxTokenIcon'; import { BitcoinLogo } from '@src/components/icons/BitcoinLogo'; -import { useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { blockchainDisplayNameMap } from '../models'; -import EthLogo from './../../../images/tokens/eth.png'; import { Button, CheckIcon, ChevronDownIcon, ChevronUpIcon, + EthereumColorIcon, Menu, MenuItem, Stack, Typography, } from '@avalabs/core-k2-components'; -import { TokenIcon } from '@src/components/common/TokenIcon'; interface NetworkSelectorProps { testId?: string; @@ -24,14 +23,18 @@ interface NetworkSelectorProps { chains: Blockchain[]; } -const EthereumLogo = () => ( - -); +const getBlockChainLogo = (blockchain: Blockchain) => { + switch (blockchain) { + case Blockchain.AVALANCHE: + return ; + case Blockchain.ETHEREUM: + return ; + case Blockchain.BITCOIN: + return ; + default: + return <>; + } +}; export function NetworkSelector({ testId, @@ -45,64 +48,57 @@ export function NetworkSelector({ const selectedDisplayValue = blockchainDisplayNameMap.get(selected); - const getBlockChainLogo = (blockchain: Blockchain) => { - switch (blockchain) { - case Blockchain.AVALANCHE: - return ; - case Blockchain.ETHEREUM: - return ; - case Blockchain.BITCOIN: - return ; - default: - return <>; - } - }; - - const handleClose = (blockchain: Blockchain) => { - setIsOpen(false); - onSelect?.(blockchain); - }; + const handleClose = useCallback( + (blockchain: Blockchain) => { + setIsOpen(false); + onSelect?.(blockchain); + }, + [onSelect] + ); - function getMenuItem(dataId: string, blockchain: Blockchain) { - if (!chains.includes(blockchain)) { - return null; - } + const getMenuItem = useCallback( + (dataId: string, blockchain: Blockchain) => { + if (!chains.includes(blockchain)) { + return null; + } - return ( - { - handleClose(blockchain); - }} - disableRipple - sx={{ minHeight: 'auto', py: 1 }} - > - { + handleClose(blockchain); }} + disableRipple + sx={{ minHeight: 'auto', py: 1 }} > - {getBlockChainLogo(blockchain)} - - {blockchainDisplayNameMap.get(blockchain)} - - + + {getBlockChainLogo(blockchain)} + + {blockchainDisplayNameMap.get(blockchain)} + + - {selected === blockchain && } - - - ); - } + {selected === blockchain && } + + + ); + }, + [chains, handleClose, selected] + ); return ( diff --git a/src/pages/Bridge/hooks/useAvalancheBridge.test.ts b/src/pages/Bridge/hooks/useAvalancheBridge.test.ts new file mode 100644 index 000000000..07aebaf92 --- /dev/null +++ b/src/pages/Bridge/hooks/useAvalancheBridge.test.ts @@ -0,0 +1,213 @@ +import Big from 'big.js'; +import { + useBridgeSDK, + Blockchain, + AssetType, + isNativeAsset, +} from '@avalabs/core-bridge-sdk'; +import { act, renderHook } from '@testing-library/react-hooks'; + +import { useBridgeContext } from '@src/contexts/BridgeProvider'; +import { useAccountsContext } from '@src/contexts/AccountsProvider'; +import { useNetworkFeeContext } from '@src/contexts/NetworkFeeProvider'; +import { useConnectionContext } from '@src/contexts/ConnectionProvider'; + +import { useAvalancheBridge } from './useAvalancheBridge'; +import { useAssetBalancesEVM } from './useAssetBalancesEVM'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k) => k, + }), +})); +jest.mock('@avalabs/core-bridge-sdk'); +jest.mock('./useAssetBalancesEVM'); +jest.mock('@src/contexts/BridgeProvider'); +jest.mock('@src/contexts/AccountsProvider'); +jest.mock('@src/contexts/NetworkFeeProvider'); +jest.mock('@src/contexts/ConnectionProvider'); + +describe('src/pages/Bridge/hooks/useAvalancheBridge', () => { + let requestFn = jest.fn(); + let createBridgeTransactionFn = jest.fn(); + let transferEVMAssetFn = jest.fn(); + + const highFee = 20n; + const lowFee = 8n; + + const currentAssetData = { + assetType: AssetType.BTC, + denomination: 8, + wrappedAssetSymbol: 'BTC.b', + tokenName: 'Bitcoin', + symbol: 'BTC', + nativeNetwork: Blockchain.BITCOIN, + }; + + const btcWithBalance = { + symbol: 'BTC', + asset: currentAssetData, + balance: new Big('0.1'), + price: 60_000, + }; + + beforeEach(() => { + jest.resetAllMocks(); + + requestFn = jest.fn(); + transferEVMAssetFn = jest.fn(); + createBridgeTransactionFn = jest.fn(); + + jest.mocked(useConnectionContext).mockReturnValue({ + request: requestFn, + } as any); + + jest.mocked(useAccountsContext).mockReturnValue({ + accounts: { + active: { + addressC: 'user-c-address', + addressBTC: 'user-btc-address', + }, + }, + } as any); + + jest.mocked(useAssetBalancesEVM).mockReturnValue({ + assetsWithBalances: [btcWithBalance], + } as any); + + jest.mocked(useBridgeContext).mockReturnValue({ + createBridgeTransaction: createBridgeTransactionFn, + transferEVMAsset: transferEVMAssetFn, + } as any); + + jest.mocked(useBridgeSDK).mockReturnValue({ + bridgeConfig: { + config: {}, + }, + currentAsset: 'BTC', + currentAssetData, + setTransactionDetails: jest.fn(), + currentBlockchain: Blockchain.AVALANCHE, + targetBlockchain: Blockchain.BITCOIN, + } as any); + + jest.mocked(useNetworkFeeContext).mockReturnValue({ + networkFee: { + high: { + maxFee: highFee, + }, + low: { + maxFee: lowFee, + }, + displayDecimals: 8, + }, + } as any); + }); + + it('provides maximum transfer amount', async () => { + const amount = new Big('0.1'); + const fee = new Big('0.0003'); + const minimum = new Big('0.0006'); + + const { result: hook } = renderHook(() => + useAvalancheBridge(amount, fee, minimum) + ); + + // Wait for the state to be set + await new Promise(process.nextTick); + + expect(hook.current.maximum).toEqual(btcWithBalance.balance); + }); + + describe('transfer()', () => { + beforeEach(() => { + jest.mocked(isNativeAsset).mockReturnValue(true); + }); + + describe('when no asset is selected', () => { + beforeEach(() => { + jest.mocked(useBridgeSDK).mockReturnValue({ + bridgeConfig: { + config: {}, + }, + currentAsset: '', + currentAssetData: undefined, + setTransactionDetails: jest.fn(), + currentBlockchain: Blockchain.ETHEREUM, + } as any); + }); + + it('throws error', async () => { + const amount = new Big('0.1'); + const fee = new Big('0.0003'); + const minimum = new Big('0.0006'); + + const { result: hook } = renderHook(() => + useAvalancheBridge(amount, fee, minimum) + ); + + await act(async () => { + await expect(hook.current.transfer()).rejects.toThrow( + 'No asset selected' + ); + }); + }); + }); + + it("calls the provider's transfer function", async () => { + const amount = new Big('0.1'); + const fee = new Big('0.0003'); + const minimum = new Big('0.0006'); + + const { result: hook } = renderHook(() => + useAvalancheBridge(amount, fee, minimum) + ); + + const fakeHash = '0xHash'; + + transferEVMAssetFn.mockResolvedValue({ + hash: fakeHash, + }); + + await act(async () => { + const hash = await hook.current.transfer(); + + expect(transferEVMAssetFn).toHaveBeenCalledWith( + amount, + currentAssetData + ); + + expect(hash).toEqual(fakeHash); + }); + }); + + it('tracks the bridge transaction', async () => { + const amount = new Big('0.1'); + const fee = new Big('0.0003'); + const minimum = new Big('0.0006'); + + const { result: hook } = renderHook(() => + useAvalancheBridge(amount, fee, minimum) + ); + + const fakeHash = '0xHash'; + + transferEVMAssetFn.mockResolvedValue({ + hash: fakeHash, + }); + + await act(async () => { + await hook.current.transfer(); + + expect(createBridgeTransactionFn).toHaveBeenCalledWith({ + sourceChain: Blockchain.AVALANCHE, + sourceTxHash: fakeHash, + sourceStartedAt: expect.any(Number), + targetChain: Blockchain.BITCOIN, + amount, + symbol: 'BTC', + }); + }); + }); + }); +}); diff --git a/src/pages/Bridge/hooks/useAvalancheBridge.ts b/src/pages/Bridge/hooks/useAvalancheBridge.ts index 4735a6cf6..01cf8c360 100644 --- a/src/pages/Bridge/hooks/useAvalancheBridge.ts +++ b/src/pages/Bridge/hooks/useAvalancheBridge.ts @@ -1,11 +1,9 @@ import { BIG_ZERO, Blockchain, useBridgeSDK } from '@avalabs/core-bridge-sdk'; import { BridgeAdapter } from './useBridge'; import { useAssetBalancesEVM } from './useAssetBalancesEVM'; -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; import { useBridgeContext } from '@src/contexts/BridgeProvider'; import Big from 'big.js'; -import { useHasEnoughForGas } from './useHasEnoughtForGas'; -import { CustomGasSettings } from '@src/background/services/bridge/models'; /** * Hook for when the source is Avalanche @@ -13,8 +11,7 @@ import { CustomGasSettings } from '@src/background/services/bridge/models'; export function useAvalancheBridge( amount: Big, bridgeFee: Big, - minimum: Big, - gasSetting?: CustomGasSettings + minimum: Big ): BridgeAdapter { const { targetBlockchain, @@ -23,10 +20,10 @@ export function useAvalancheBridge( currentAssetData, } = useBridgeSDK(); - const { createBridgeTransaction, transferAsset } = useBridgeContext(); + const { createBridgeTransaction, transferEVMAsset, estimateGas } = + useBridgeContext(); const isAvalancheBridge = currentBlockchain === Blockchain.AVALANCHE; - const [txHash, setTxHash] = useState(); const { assetsWithBalances: selectedAssetWithBalances } = useAssetBalancesEVM( Blockchain.AVALANCHE, @@ -35,60 +32,49 @@ export function useAvalancheBridge( const sourceBalance = selectedAssetWithBalances[0]; const { assetsWithBalances } = useAssetBalancesEVM(Blockchain.AVALANCHE); - const hasEnoughForNetworkFee = useHasEnoughForGas(gasSetting?.gasLimit); const maximum = sourceBalance?.balance || BIG_ZERO; const receiveAmount = amount.gt(minimum) ? amount.minus(bridgeFee) : BIG_ZERO; - const transfer = useCallback( - async (customGasSettings: CustomGasSettings) => { - if (!currentAssetData) return Promise.reject(); + const transfer = useCallback(async () => { + if (!currentAssetData) { + throw new Error('No asset selected'); + } - const timestamp = Date.now(); - const result = await transferAsset( - amount, - currentAssetData, - customGasSettings, - () => { - //not used - }, - setTxHash - ); + const timestamp = Date.now(); + const result = await transferEVMAsset(amount, currentAssetData); - setTransactionDetails({ - tokenSymbol: currentAssetData.symbol, - amount, - }); - - createBridgeTransaction({ - sourceChain: Blockchain.AVALANCHE, - sourceTxHash: result.hash, - sourceStartedAt: timestamp, - targetChain: targetBlockchain, - amount, - symbol: currentAssetData.symbol, - }); + setTransactionDetails({ + tokenSymbol: currentAssetData.symbol, + amount, + }); - return result.hash; - }, - [ + createBridgeTransaction({ + sourceChain: Blockchain.AVALANCHE, + sourceTxHash: result.hash, + sourceStartedAt: timestamp, + targetChain: targetBlockchain, amount, - createBridgeTransaction, - currentAssetData, - setTransactionDetails, - targetBlockchain, - transferAsset, - ] - ); + symbol: currentAssetData.symbol, + }); + + return result.hash; + }, [ + amount, + createBridgeTransaction, + currentAssetData, + setTransactionDetails, + targetBlockchain, + transferEVMAsset, + ]); return { sourceBalance, assetsWithBalances, - hasEnoughForNetworkFee, receiveAmount, maximum, price: sourceBalance?.price, - txHash, + estimateGas, transfer, }; } diff --git a/src/pages/Bridge/hooks/useBridge.ts b/src/pages/Bridge/hooks/useBridge.ts index 08db25816..f8b750b9d 100644 --- a/src/pages/Bridge/hooks/useBridge.ts +++ b/src/pages/Bridge/hooks/useBridge.ts @@ -8,26 +8,19 @@ import { useMinimumTransferAmount, Asset, } from '@avalabs/core-bridge-sdk'; -import { Dispatch, SetStateAction, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { AssetBalance } from '../models'; -import { useBtcBridge } from './useBtcBridge'; -import { useEthBridge } from './useEthBridge'; -import { useAvalancheBridge } from './useAvalancheBridge'; import { useUnifiedBridgeContext } from '@src/contexts/UnifiedBridgeProvider'; -import { useUnifiedBridge } from './useUnifiedBridge'; import { useNetworkContext } from '@src/contexts/NetworkProvider'; import { ChainId } from '@avalabs/core-chains-sdk'; import { BridgeStepDetails } from '@avalabs/bridge-unified'; -import { useBridgeContext } from '@src/contexts/BridgeProvider'; -import { CustomGasSettings } from '@src/background/services/bridge/models'; export interface BridgeAdapter { address?: string; sourceBalance?: AssetBalance; targetBalance?: AssetBalance; assetsWithBalances?: AssetBalance[]; - hasEnoughForNetworkFee: boolean; loading?: boolean; networkFee?: Big; bridgeFee?: Big; @@ -45,21 +38,18 @@ export interface BridgeAdapter { * Transfer funds to the target blockchain * @returns the transaction hash */ - transfer: ( - customGasSettings: CustomGasSettings - ) => Promise; - estimateGas?(amount: Big, asset?: Asset): Promise; + transfer: () => Promise; + estimateGas(amount: Big, asset?: Asset): Promise; bridgeStep?: BridgeStepDetails; } -interface Bridge extends BridgeAdapter { +interface Bridge { amount: Big; setAmount: (amount: Big) => void; - setGasSettings: Dispatch>; - estimateGas(amount: Big, asset?: Asset): Promise; - gasSettings: CustomGasSettings; - bridgeFee?: Big; + bridgeFee: Big; provider: BridgeProviders; + minimum: Big; + targetChainId: number; } export enum BridgeProviders { @@ -67,17 +57,12 @@ export enum BridgeProviders { Unified, } -const DEFAULT_GAS_SETTINGS = {}; - export function useBridge(currentAssetAddress?: string): Bridge { - const { currentBlockchain: source, targetBlockchain } = useBridgeSDK(); - const { estimateGas } = useBridgeContext(); + const { targetBlockchain } = useBridgeSDK(); const { supportsAsset } = useUnifiedBridgeContext(); const [amount, setAmount] = useState(BIG_ZERO); - const [gasSettings, setGasSettings] = useState(DEFAULT_GAS_SETTINGS); - const bridgeFee = useBridgeFeeEstimate(amount) || BIG_ZERO; const minimum = useMinimumTransferAmount(amount); const { isDeveloperMode } = useNetworkContext(); @@ -107,73 +92,15 @@ export function useBridge(currentAssetAddress?: string): Bridge { } }, [isDeveloperMode, targetBlockchain]); - const btc = useBtcBridge(amount); - const eth = useEthBridge(amount, bridgeFee, minimum, gasSettings); - const avalanche = useAvalancheBridge(amount, bridgeFee, minimum); - const unified = useUnifiedBridge( + return { amount, + setAmount, + minimum, + bridgeFee, targetChainId, - currentAssetAddress, - gasSettings - ); - - const defaults = useMemo( - () => ({ - amount, - minimum, - setAmount, - bridgeFee, - estimateGas, - gasSettings, - setGasSettings, - provider: BridgeProviders.Avalanche, - }), - [amount, bridgeFee, minimum, estimateGas, gasSettings] - ); - - const bridge = useMemo(() => { - if ( - currentAssetAddress && - supportsAsset(currentAssetAddress, targetChainId) - ) { - return { - ...defaults, - ...unified, - provider: BridgeProviders.Unified, - }; - } else if (source === Blockchain.BITCOIN) { - return { - ...defaults, - ...btc, - }; - } else if (source === Blockchain.ETHEREUM) { - return { - ...defaults, - ...eth, - }; - } else if (source === Blockchain.AVALANCHE) { - return { - ...defaults, - ...avalanche, - }; - } else { - return { - ...defaults, - hasEnoughForNetworkFee: true, - transfer: () => Promise.reject('invalid bridge'), - }; - } - }, [ - avalanche, - btc, - defaults, - eth, - unified, - source, - currentAssetAddress, - supportsAsset, - targetChainId, - ]); - - return bridge; + provider: + currentAssetAddress && supportsAsset(currentAssetAddress, targetChainId) + ? BridgeProviders.Unified + : BridgeProviders.Avalanche, + }; } diff --git a/src/pages/Bridge/hooks/useBridgeTxHandling.ts b/src/pages/Bridge/hooks/useBridgeTxHandling.ts new file mode 100644 index 000000000..f23e30402 --- /dev/null +++ b/src/pages/Bridge/hooks/useBridgeTxHandling.ts @@ -0,0 +1,47 @@ +import { handleTxOutcome } from '@src/utils/handleTxOutcome'; +import { useCallback, useState } from 'react'; + +export const useBridgeTxHandling = ({ + transfer, + onInitiated, + onSuccess, + onFailure, + onRejected, +}: { + transfer: () => Promise; + onInitiated: () => void; + onSuccess: (txHash: string) => void; + onFailure: (error: unknown) => void; + onRejected: () => void; +}) => { + const [isPending, setIsPending] = useState(false); + + const onTransfer = useCallback(async () => { + setIsPending(true); + + try { + onInitiated(); + + const { + isApproved, + hasError, + result: txHash, + error: txError, + } = await handleTxOutcome(transfer()); + + if (isApproved) { + if (hasError) { + onFailure(txError); + } else { + onSuccess(txHash); + } + } else { + onRejected(); + } + } finally { + setIsPending(false); + } + }, [onInitiated, onRejected, onFailure, onSuccess, transfer]); + + return { onTransfer, isPending }; +}; diff --git a/src/pages/Bridge/hooks/useBtcBridge.test.ts b/src/pages/Bridge/hooks/useBtcBridge.test.ts new file mode 100644 index 000000000..ca7ba3691 --- /dev/null +++ b/src/pages/Bridge/hooks/useBtcBridge.test.ts @@ -0,0 +1,245 @@ +import Big from 'big.js'; +import { BN } from 'bn.js'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { + useBridgeConfig, + useBridgeSDK, + getBtcAsset, + Blockchain, + btcToSatoshi, +} from '@avalabs/core-bridge-sdk'; + +import { useBridgeContext } from '@src/contexts/BridgeProvider'; +import { useAccountsContext } from '@src/contexts/AccountsProvider'; +import { useNetworkFeeContext } from '@src/contexts/NetworkFeeProvider'; +import { useConnectionContext } from '@src/contexts/ConnectionProvider'; +import { useTokensWithBalances } from '@src/hooks/useTokensWithBalances'; +import { DAppProviderRequest } from '@src/background/connections/dAppConnection/models'; + +import { useBtcBridge } from './useBtcBridge'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k) => k, + }), +})); +jest.mock('@avalabs/core-bridge-sdk'); +jest.mock('@src/contexts/BridgeProvider'); +jest.mock('@src/contexts/AccountsProvider'); +jest.mock('@src/contexts/NetworkFeeProvider'); +jest.mock('@src/contexts/ConnectionProvider'); +jest.mock('@src/hooks/useTokensWithBalances'); + +describe('src/pages/Bridge/hooks/useBtcBridge', () => { + let requestFn = jest.fn(); + let createBridgeTransactionFn = jest.fn(); + + const highFee = 20n; + const lowFee = 8n; + + beforeEach(() => { + jest.resetAllMocks(); + + requestFn = jest.fn(); + createBridgeTransactionFn = jest.fn(); + + jest.mocked(useConnectionContext).mockReturnValue({ + request: requestFn, + } as any); + + jest.mocked(useAccountsContext).mockReturnValue({ + accounts: { + active: { + addressBTC: 'user-btc-address', + }, + }, + } as any); + + jest.mocked(useTokensWithBalances).mockReturnValue([ + { + symbol: 'BTC', + decimals: 8, + balance: new BN('10000000', 8), + } as any, + ]); + + jest.mocked(useBridgeContext).mockReturnValue({ + createBridgeTransaction: createBridgeTransactionFn, + } as any); + + jest.mocked(getBtcAsset).mockReturnValue({ + symbol: 'BTC', + denomination: 8, + tokenName: 'Bitcoin', + } as any); + + jest.mocked(useBridgeSDK).mockReturnValue({ + setTransactionDetails: jest.fn(), + currentBlockchain: Blockchain.BITCOIN, + } as any); + + jest.mocked(useBridgeConfig).mockReturnValue({ + config: { + criticalBitcoin: { + walletAddresses: { + btc: 'bridge-btc-address', + avalanche: 'bridge-avax-address', + }, + }, + }, + } as any); + + jest.mocked(useNetworkFeeContext).mockReturnValue({ + networkFee: { + high: { + maxFee: highFee, + }, + low: { + maxFee: lowFee, + }, + displayDecimals: 8, + }, + } as any); + }); + + describe('transfer()', () => { + describe('when active account has no BTC address', () => { + beforeEach(() => { + jest.mocked(useAccountsContext).mockReturnValue({ + accounts: { + active: { + addressC: 'user-c-address', + addressBTC: '', + }, + }, + } as any); + }); + + it('throws error', async () => { + const amount = new Big('0.1'); + const { result: hook } = renderHook(() => useBtcBridge(amount)); + + await act(async () => { + await expect(hook.current.transfer()).rejects.toThrow( + 'Unsupported account' + ); + }); + }); + }); + + describe('when bridge config is not loaded yet', () => { + beforeEach(() => { + jest.mocked(useBridgeConfig).mockReturnValue({ config: undefined }); + }); + + it('throws error', async () => { + const amount = new Big('0.1'); + const { result: hook } = renderHook(() => useBtcBridge(amount)); + + await act(async () => { + await expect(hook.current.transfer()).rejects.toThrow( + 'Bridge not ready' + ); + }); + }); + }); + + describe('when fee rate is not loaded yet', () => { + beforeEach(() => { + jest.mocked(useNetworkFeeContext).mockReturnValue({ + currentFeeInfo: null, + } as any); + }); + + it('throws error', async () => { + const amount = new Big('0.1'); + const { result: hook } = renderHook(() => useBtcBridge(amount)); + + await act(async () => { + await expect(hook.current.transfer()).rejects.toThrow( + 'Bridge not ready' + ); + }); + }); + }); + + describe('when fee rate is not loaded yet', () => { + beforeEach(() => { + jest.mocked(useNetworkFeeContext).mockReturnValue({ + networkFee: null, + } as any); + }); + + it('throws error', async () => { + const amount = new Big('0.1'); + const { result: hook } = renderHook(() => useBtcBridge(amount)); + + await act(async () => { + await expect(hook.current.transfer()).rejects.toThrow( + 'Bridge not ready' + ); + }); + }); + }); + + describe('when amount is not provided', () => { + it('throws error', async () => { + const amount = new Big('0'); + const { result: hook } = renderHook(() => useBtcBridge(amount)); + + await act(async () => { + await expect(hook.current.transfer()).rejects.toThrow( + 'Amount not provided' + ); + }); + }); + }); + + it('sends a bitcoin_sendTransaction request with proper parameters', async () => { + const amount = new Big('0.1'); + const { result: hook } = renderHook(() => useBtcBridge(amount)); + + const fakeHash = '0xTxHash'; + + requestFn.mockResolvedValue(fakeHash); + + await act(async () => { + const hash = await hook.current.transfer(); + + expect(requestFn).toHaveBeenCalledWith({ + method: DAppProviderRequest.BITCOIN_SEND_TRANSACTION, + params: [ + 'bridge-btc-address', + String(btcToSatoshi(amount)), + Number(highFee), + { customApprovalScreenTitle: 'Confirm Bridge' }, + ], + }); + + expect(hash).toEqual(fakeHash); + }); + }); + + it('tracks the bridge transaction', async () => { + const amount = new Big('0.1'); + const { result: hook } = renderHook(() => useBtcBridge(amount)); + + const fakeHash = '0xTxHash'; + + requestFn.mockResolvedValue(fakeHash); + + await act(async () => { + await hook.current.transfer(); + + expect(createBridgeTransactionFn).toHaveBeenCalledWith({ + sourceChain: Blockchain.BITCOIN, + sourceTxHash: fakeHash, + sourceStartedAt: expect.any(Number), + targetChain: Blockchain.AVALANCHE, + amount, + symbol: 'BTC', + }); + }); + }); + }); +}); diff --git a/src/pages/Bridge/hooks/useBtcBridge.ts b/src/pages/Bridge/hooks/useBtcBridge.ts index d08f6ac1b..22c89c060 100644 --- a/src/pages/Bridge/hooks/useBtcBridge.ts +++ b/src/pages/Bridge/hooks/useBtcBridge.ts @@ -13,52 +13,37 @@ import { BitcoinInputUTXOWithOptionalScript, getMaxTransferAmount, } from '@avalabs/core-wallets-sdk'; -import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; import { useAccountsContext } from '@src/contexts/AccountsProvider'; import { useBridgeContext } from '@src/contexts/BridgeProvider'; import { useConnectionContext } from '@src/contexts/ConnectionProvider'; import { useNetworkContext } from '@src/contexts/NetworkProvider'; import { useNetworkFeeContext } from '@src/contexts/NetworkFeeProvider'; -import { useInterval } from '@src/hooks/useInterval'; import Big from 'big.js'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { AssetBalance } from '../models'; import { BridgeAdapter } from './useBridge'; -import { NetworkFee } from '@src/background/services/networkFee/models'; -import { BridgeTransferAssetHandler } from '@src/background/services/bridge/handlers/transferAsset'; +import { TransactionPriority } from '@src/background/services/networkFee/models'; import { useTokensWithBalances } from '@src/hooks/useTokensWithBalances'; -import { CustomGasSettings } from '@src/background/services/bridge/models'; import { TokenWithBalanceBTC } from '@src/background/services/balances/models'; -import { chainIdToCaip } from '@src/utils/caipConversion'; -const NETWORK_FEE_REFRESH_INTERVAL = 60_000; +import { getBtcInputUtxos } from '@src/utils/send/btcSendUtils'; +import { BitcoinSendTransactionHandler } from '@src/background/services/wallet/handlers/bitcoin_sendTransaction'; +import { DAppProviderRequest } from '@src/background/connections/dAppConnection/models'; +import { useTranslation } from 'react-i18next'; /** * Hook for Bitcoin to Avalanche transactions */ export function useBtcBridge(amountInBtc: Big): BridgeAdapter { - const { - currentAsset, - setTransactionDetails, - currentBlockchain, - targetBlockchain, - } = useBridgeSDK(); - const isBitcoinBridge = - currentBlockchain === Blockchain.BITCOIN || - targetBlockchain === Blockchain.BITCOIN; - - const refetchFeeTrigger = useInterval(NETWORK_FEE_REFRESH_INTERVAL); + const { t } = useTranslation(); + const { setTransactionDetails, currentBlockchain } = useBridgeSDK(); + const isBitcoinBridge = currentBlockchain === Blockchain.BITCOIN; + const { request } = useConnectionContext(); - const { isDeveloperMode } = useNetworkContext(); - const { getNetworkFee } = useNetworkFeeContext(); + const { bitcoinProvider, isDeveloperMode } = useNetworkContext(); + const { networkFee: currentFeeInfo } = useNetworkFeeContext(); const { config } = useBridgeConfig(); const { createBridgeTransaction } = useBridgeContext(); - const avalancheTokens = useTokensWithBalances({ - forceShowTokensWithoutBalances: true, - chainId: isDeveloperMode - ? ChainId.AVALANCHE_TESTNET_ID - : ChainId.AVALANCHE_MAINNET_ID, - }); const btcTokens = useTokensWithBalances({ forceShowTokensWithoutBalances: true, chainId: isDeveloperMode ? ChainId.BITCOIN_TESTNET : ChainId.BITCOIN, @@ -68,109 +53,100 @@ export function useBtcBridge(amountInBtc: Big): BridgeAdapter { } = useAccountsContext(); const [btcBalance, setBtcBalance] = useState(); - const [btcBalanceAvalanche, setBtcBalanceAvalanche] = - useState(); - const [utxos, setUtxos] = useState(); - const [feeRates, setFeeRates] = useState(); + const [utxos, setUtxos] = useState([]); + + const btcToken = useMemo( + () => + btcTokens.find((tok): tok is TokenWithBalanceBTC => tok.symbol === 'BTC'), + [btcTokens] + ); + // Update the fee rate so we're able to calculate the + // max. bridgable amount for the main bridge screen const feeRate: number = useMemo(() => { - return Number(feeRates?.high.maxFee || 0n); - }, [feeRates]); + if (!currentFeeInfo) { + return 0; + } + + // Because BTC testnet fees are super high recently, + // defaulting to cheaper preset makes testing easier. + const preset: TransactionPriority = isDeveloperMode ? 'low' : 'high'; + + return Number(currentFeeInfo[preset].maxFee); + }, [currentFeeInfo, isDeveloperMode]); + // Calculate the maximum bridgable BTC amount whwnever const maximum = useMemo(() => { - if (!config || !activeAccount || !activeAccount.addressBTC) { + if (!feeRate || !config || !activeAccount?.addressBTC) { return Big(0); } const maxAmt = getMaxTransferAmount( - utxos || [], + utxos, // As long as the address type is the same (P2WPKH) it should not matter. config.criticalBitcoin.walletAddresses.btc, activeAccount.addressBTC, feeRate ); + return satoshiToBtc(maxAmt); - }, [utxos, config, feeRate, activeAccount]); + }, [utxos, config, feeRate, activeAccount?.addressBTC]); - /** Network fee (in BTC) */ - const [networkFee, setNetworkFee] = useState(); /** Amount minus network and bridge fees (in BTC) */ const [receiveAmount, setReceiveAmount] = useState(); - const loading = !btcBalance || !btcBalanceAvalanche || !networkFee; + const loading = !btcBalance || !currentFeeInfo || !feeRate; const amountInSatoshis = btcToSatoshi(amountInBtc); const btcAsset = config && getBtcAsset(config); const assetsWithBalances = btcBalance ? [btcBalance] : []; + // Update balances for the UI useEffect(() => { - async function loadFeeRates() { - if (isBitcoinBridge && refetchFeeTrigger) { - const rates = await getNetworkFee( - chainIdToCaip( - isDeveloperMode ? ChainId.BITCOIN_TESTNET : ChainId.BITCOIN - ) - ); - setFeeRates(rates); - } + if (!isBitcoinBridge || !btcAsset || !btcToken) { + return; } - loadFeeRates(); - }, [getNetworkFee, isBitcoinBridge, isDeveloperMode, refetchFeeTrigger]); + setBtcBalance({ + symbol: btcAsset.symbol, + asset: btcAsset, + balance: satoshiToBtc(btcToken.balance.toNumber()), + logoUri: btcToken.logoUri, + price: btcToken.priceUSD, + unconfirmedBalance: btcToken.unconfirmedBalance + ? satoshiToBtc(btcToken.unconfirmedBalance.toNumber()) + : satoshiToBtc(0), + }); + }, [btcToken, btcAsset, isBitcoinBridge]); - // balances, utxos + // Filter UTXOs whenever balance or fee rate is updated + // so we can calculate the max. bridgable amount. useEffect(() => { - const { addressC, addressBTC } = activeAccount ?? {}; - - if (isBitcoinBridge && btcAsset && addressC && addressBTC) { - const balance = btcTokens?.find( - (token) => token.symbol === 'BTC' - ) as TokenWithBalanceBTC; - - if (balance) { - setUtxos(balance.utxos); - setBtcBalance({ - symbol: btcAsset.symbol, - asset: btcAsset, - balance: satoshiToBtc(balance.balance.toNumber()), - logoUri: balance.logoUri, - price: balance.priceUSD, - unconfirmedBalance: balance?.unconfirmedBalance - ? satoshiToBtc(balance.unconfirmedBalance.toNumber()) - : satoshiToBtc(0), - }); - } - - const btcAvalancheBalance = avalancheTokens?.find( - (token) => token.symbol === 'BTC.b' - ); + let isMounted = true; - if (btcAvalancheBalance) { - setBtcBalanceAvalanche({ - symbol: btcAsset.symbol, - asset: btcAsset, - balance: satoshiToBtc(btcAvalancheBalance.balance?.toNumber() || 0), - logoUri: btcAvalancheBalance.logoUri, - price: btcAvalancheBalance.priceUSD, - unconfirmedBalance: satoshiToBtc( - btcAvalancheBalance.balance?.toNumber() || 0 - ), - }); - } + if (!bitcoinProvider || !feeRate || !btcToken) { + return; } - }, [ - activeAccount, - avalancheTokens, - btcTokens, - btcAsset, - isBitcoinBridge, - isDeveloperMode, - request, - ]); - useEffect(() => { - if (!isBitcoinBridge || !config || !activeAccount || !utxos) return; + getBtcInputUtxos(bitcoinProvider, btcToken, feeRate) + .then((_utxos) => { + if (isMounted) { + setUtxos(_utxos); + } + }) + .catch((err) => { + console.error(err); + if (isMounted) { + setUtxos([]); + } + }); + + return () => { + isMounted = false; + }; + }, [bitcoinProvider, btcToken, feeRate]); - if (!activeAccount.addressBTC) { + useEffect(() => { + if (!isBitcoinBridge || !config || !activeAccount?.addressBTC || !utxos) { return; } @@ -183,77 +159,107 @@ export function useBtcBridge(amountInBtc: Big): BridgeAdapter { feeRate ); - setNetworkFee(satoshiToBtc(btcTx.fee)); setReceiveAmount(satoshiToBtc(btcTx.receiveAmount)); } catch (error) { // getBtcTransaction throws an error when the amount is too low or too high // so set these to 0 - setNetworkFee(BIG_ZERO); setReceiveAmount(BIG_ZERO); } }, [ amountInSatoshis, - activeAccount, + activeAccount?.addressBTC, config, isBitcoinBridge, utxos, feeRate, ]); - const transfer = useCallback( - async (customGasSettings: CustomGasSettings) => { - if (!isBitcoinBridge || !config || !activeAccount || !btcAsset || !utxos) - return; + const transferBTC = useCallback(async () => { + if (!config || !feeRate) { + throw new Error('Bridge not ready'); + } - const timestamp = Date.now(); - const symbol = currentAsset || ''; + if (amountInBtc.lte(0)) { + throw new Error('Amount not provided'); + } - const result = await request({ - method: ExtensionRequest.BRIDGE_TRANSFER_ASSET, - params: [currentBlockchain, amountInBtc, btcAsset, customGasSettings], - }); + if (!activeAccount?.addressBTC) { + throw new Error('Unsupported account'); + } - setTransactionDetails({ - tokenSymbol: symbol, - amount: amountInBtc, - }); - createBridgeTransaction({ - sourceChain: Blockchain.BITCOIN, - sourceTxHash: result.hash, - sourceStartedAt: timestamp, - targetChain: Blockchain.AVALANCHE, - amount: amountInBtc, - symbol, - }); + const symbol = 'BTC'; + const hash = await request< + BitcoinSendTransactionHandler, + DAppProviderRequest.BITCOIN_SEND_TRANSACTION, + string + >({ + method: DAppProviderRequest.BITCOIN_SEND_TRANSACTION, + params: [ + config.criticalBitcoin.walletAddresses.btc, + String(btcToSatoshi(amountInBtc)), + feeRate, + { customApprovalScreenTitle: t('Confirm Bridge') }, + ], + }); + + setTransactionDetails({ + tokenSymbol: symbol, + amount: amountInBtc, + }); + createBridgeTransaction({ + sourceChain: Blockchain.BITCOIN, + sourceTxHash: hash, + sourceStartedAt: Date.now(), + targetChain: Blockchain.AVALANCHE, + amount: amountInBtc, + symbol, + }); - return result.hash; + return hash; + }, [ + request, + activeAccount?.addressBTC, + t, + amountInBtc, + config, + createBridgeTransaction, + feeRate, + setTransactionDetails, + ]); + + const estimateGas = useCallback( + async (amount: Big) => { + if (!config || !activeAccount?.addressBTC) { + return; + } + + // Bitcoin's formula for fee is `transactionByteLength * feeRate`. + // By setting the feeRate here to 1, we'll receive the transaction's byte length, + // which is what we need to have the dynamic fee calculations in the UI. + // Think of the byteLength as gasLimit for EVM transactions. + const fakeFeeRate = 1; + const { fee: byteLength } = getBtcTransactionDetails( + config, + activeAccount.addressBTC, + utxos, + btcToSatoshi(amount), + fakeFeeRate + ); + + return BigInt(byteLength); }, - [ - isBitcoinBridge, - config, - activeAccount, - btcAsset, - utxos, - currentAsset, - request, - currentBlockchain, - amountInBtc, - setTransactionDetails, - createBridgeTransaction, - ] + [activeAccount?.addressBTC, config, utxos] ); return { address: activeAccount?.addressBTC, sourceBalance: btcBalance, - targetBalance: btcBalanceAvalanche, assetsWithBalances, - hasEnoughForNetworkFee: true, // minimum calc covers this loading, - networkFee, receiveAmount, maximum, price: btcBalance?.price, - transfer, + estimateGas, + transfer: transferBTC, }; } diff --git a/src/pages/Bridge/hooks/useEthBridge.test.ts b/src/pages/Bridge/hooks/useEthBridge.test.ts new file mode 100644 index 000000000..184ca508f --- /dev/null +++ b/src/pages/Bridge/hooks/useEthBridge.test.ts @@ -0,0 +1,228 @@ +import Big from 'big.js'; +import { + useBridgeSDK, + Blockchain, + AssetType, + isNativeAsset, + getMaxTransferAmount, +} from '@avalabs/core-bridge-sdk'; +import { act, renderHook } from '@testing-library/react-hooks'; + +import { useBridgeContext } from '@src/contexts/BridgeProvider'; +import { useAccountsContext } from '@src/contexts/AccountsProvider'; +import { useNetworkFeeContext } from '@src/contexts/NetworkFeeProvider'; +import { useConnectionContext } from '@src/contexts/ConnectionProvider'; + +import { useEthBridge } from './useEthBridge'; +import { useAssetBalancesEVM } from './useAssetBalancesEVM'; +import { useNetworkContext } from '@src/contexts/NetworkProvider'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k) => k, + }), +})); +jest.mock('@avalabs/core-bridge-sdk'); +jest.mock('./useAssetBalancesEVM'); +jest.mock('@src/contexts/BridgeProvider'); +jest.mock('@src/contexts/NetworkProvider'); +jest.mock('@src/contexts/AccountsProvider'); +jest.mock('@src/contexts/NetworkFeeProvider'); +jest.mock('@src/contexts/ConnectionProvider'); + +describe('src/pages/Bridge/hooks/useEthBridge', () => { + let requestFn = jest.fn(); + let createBridgeTransactionFn = jest.fn(); + let transferEVMAssetFn = jest.fn(); + + const highFee = 20n; + const lowFee = 8n; + + const currentAssetData = { + assetType: AssetType.NATIVE, + denomination: 18, + wrappedAssetSymbol: 'WETH', + tokenName: 'Ethereum', + symbol: 'ETH', + nativeNetwork: Blockchain.ETHEREUM, + }; + + beforeEach(() => { + jest.resetAllMocks(); + + requestFn = jest.fn(); + transferEVMAssetFn = jest.fn(); + createBridgeTransactionFn = jest.fn(); + + jest.mocked(getMaxTransferAmount).mockResolvedValue(new Big('0.01')); + + jest.mocked(useConnectionContext).mockReturnValue({ + request: requestFn, + } as any); + + jest.mocked(useAccountsContext).mockReturnValue({ + accounts: { + active: { + addressC: 'user-c-address', + addressBTC: 'user-btc-address', + }, + }, + } as any); + + jest.mocked(useAssetBalancesEVM).mockReturnValue({ + assetsWithBalances: [ + { + symbol: 'ETH', + asset: currentAssetData, + balance: new Big('0.1'), + price: 2000, + }, + ], + } as any); + + jest.mocked(useBridgeContext).mockReturnValue({ + createBridgeTransaction: createBridgeTransactionFn, + transferEVMAsset: transferEVMAssetFn, + } as any); + + jest.mocked(useBridgeSDK).mockReturnValue({ + bridgeConfig: { + config: {}, + }, + currentAsset: 'ETH', + currentAssetData, + setTransactionDetails: jest.fn(), + currentBlockchain: Blockchain.ETHEREUM, + } as any); + + jest.mocked(useNetworkFeeContext).mockReturnValue({ + networkFee: { + high: { + maxFee: highFee, + }, + low: { + maxFee: lowFee, + }, + displayDecimals: 8, + }, + } as any); + + jest.mocked(useNetworkContext).mockReturnValue({ + ethereumProvider: {}, + } as any); + }); + + it('provides maximum transfer amount', async () => { + const amount = new Big('0.1'); + const fee = new Big('0.0003'); + const minimum = new Big('0.0006'); + + const fakeMax = new Big('0.9'); + jest.mocked(getMaxTransferAmount).mockResolvedValue(fakeMax); + + const { result: hook } = renderHook(() => + useEthBridge(amount, fee, minimum) + ); + + await act(async () => { + expect(getMaxTransferAmount).toHaveBeenCalled(); + }); + + // Wait for the state to be set + await new Promise(process.nextTick); + + expect(hook.current.maximum).toEqual(fakeMax); + }); + + describe('transfer()', () => { + beforeEach(() => { + jest.mocked(isNativeAsset).mockReturnValue(true); + }); + + describe('when no asset is selected', () => { + beforeEach(() => { + jest.mocked(useBridgeSDK).mockReturnValue({ + bridgeConfig: { + config: {}, + }, + currentAsset: '', + currentAssetData: undefined, + setTransactionDetails: jest.fn(), + currentBlockchain: Blockchain.ETHEREUM, + } as any); + }); + + it('throws error', async () => { + const amount = new Big('0.1'); + const fee = new Big('0.0003'); + const minimum = new Big('0.0006'); + + const { result: hook } = renderHook(() => + useEthBridge(amount, fee, minimum) + ); + + await act(async () => { + await expect(hook.current.transfer()).rejects.toThrow( + 'No asset selected' + ); + }); + }); + }); + + it("calls the provider's transfer function", async () => { + const amount = new Big('0.1'); + const fee = new Big('0.0003'); + const minimum = new Big('0.0006'); + + const { result: hook } = renderHook(() => + useEthBridge(amount, fee, minimum) + ); + + const fakeHash = '0xHash'; + + transferEVMAssetFn.mockResolvedValue({ + hash: fakeHash, + }); + + await act(async () => { + const hash = await hook.current.transfer(); + + expect(transferEVMAssetFn).toHaveBeenCalledWith( + amount, + currentAssetData + ); + + expect(hash).toEqual(fakeHash); + }); + }); + + it('tracks the bridge transaction', async () => { + const amount = new Big('0.1'); + const fee = new Big('0.0003'); + const minimum = new Big('0.0006'); + + const { result: hook } = renderHook(() => + useEthBridge(amount, fee, minimum) + ); + + const fakeHash = '0xHash'; + + transferEVMAssetFn.mockResolvedValue({ + hash: fakeHash, + }); + + await act(async () => { + await hook.current.transfer(); + + expect(createBridgeTransactionFn).toHaveBeenCalledWith({ + sourceChain: Blockchain.ETHEREUM, + sourceTxHash: fakeHash, + sourceStartedAt: expect.any(Number), + targetChain: Blockchain.AVALANCHE, + amount, + symbol: 'WETH', + }); + }); + }); + }); +}); diff --git a/src/pages/Bridge/hooks/useEthBridge.ts b/src/pages/Bridge/hooks/useEthBridge.ts index dede48c99..59d74f041 100644 --- a/src/pages/Bridge/hooks/useEthBridge.ts +++ b/src/pages/Bridge/hooks/useEthBridge.ts @@ -2,19 +2,16 @@ import Big from 'big.js'; import { BIG_ZERO, Blockchain, + getAssets, + getMaxTransferAmount, isNativeAsset, useBridgeSDK, - WrapStatus, } from '@avalabs/core-bridge-sdk'; import { useBridgeContext } from '@src/contexts/BridgeProvider'; import { useCallback, useEffect, useState } from 'react'; import { useAssetBalancesEVM } from './useAssetBalancesEVM'; import { BridgeAdapter } from './useBridge'; -import { useHasEnoughForGas } from './useHasEnoughtForGas'; -import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; -import { GetEthMaxTransferAmountHandler } from '@src/background/services/bridge/handlers/getEthMaxTransferAmount'; -import { useConnectionContext } from '@src/contexts/ConnectionProvider'; -import { CustomGasSettings } from '@src/background/services/bridge/models'; +import { useNetworkContext } from '@src/contexts/NetworkProvider'; /** * Hook for when the bridge source chain is Ethereum @@ -22,100 +19,116 @@ import { CustomGasSettings } from '@src/background/services/bridge/models'; export function useEthBridge( amount: Big, bridgeFee: Big, - minimum: Big, - gasSetting?: CustomGasSettings + minimum: Big ): BridgeAdapter { const { currentAsset, currentAssetData, + bridgeConfig, setTransactionDetails, currentBlockchain, } = useBridgeSDK(); const [maximum, setMaximum] = useState(undefined); - const { request } = useConnectionContext(); const isEthereumBridge = currentBlockchain === Blockchain.ETHEREUM; + const { createBridgeTransaction, transferEVMAsset, estimateGas } = + useBridgeContext(); + const { assetsWithBalances } = useAssetBalancesEVM(Blockchain.ETHEREUM); + const { ethereumProvider } = useNetworkContext(); + const sourceBalance = assetsWithBalances.find( + ({ asset }) => asset.symbol === currentAsset + ); + + const receiveAmount = amount.gt(minimum) ? amount.minus(bridgeFee) : BIG_ZERO; + useEffect(() => { - if (!currentAsset || !isEthereumBridge) return; + if ( + !currentAsset || + !isEthereumBridge || + !bridgeConfig.config || + !sourceBalance?.balance || + !ethereumProvider + ) { + return; + } - // calculating the max amount for eth can take a couple seconds - // make sure we don't have a stale value + const ethereumAssets = getAssets(currentBlockchain, bridgeConfig.config); + let isMounted = true; + + // Estimating gas can take a couple seconds - reset it before calculating + // so we don't use a stale value. setMaximum(undefined); - const getMax = async () => { - const maxAmount = await request({ - method: ExtensionRequest.BRIDGE_GET_ETH_MAX_TRANSFER_AMOUNT, - params: [currentAsset], - }); - setMaximum(maxAmount || undefined); + getMaxTransferAmount({ + currentBlockchain, + balance: sourceBalance.balance, + currentAsset, + assets: ethereumAssets, + provider: ethereumProvider, + config: bridgeConfig.config, + }).then((max) => { + if (!isMounted) { + return; + } + + setMaximum(max ?? undefined); + }); + + return () => { + isMounted = false; }; - getMax(); - }, [request, currentAsset, isEthereumBridge]); + }, [ + bridgeConfig?.config, + currentAsset, + currentBlockchain, + ethereumProvider, + isEthereumBridge, + sourceBalance?.balance, + ]); - const { createBridgeTransaction, transferAsset } = useBridgeContext(); - const { assetsWithBalances } = useAssetBalancesEVM(Blockchain.ETHEREUM); - const sourceBalance = assetsWithBalances.find( - ({ asset }) => asset.symbol === currentAsset - ); + const transfer = useCallback(async () => { + if (!currentAssetData) { + throw new Error('No asset selected'); + } - const hasEnoughForNetworkFee = useHasEnoughForGas(gasSetting?.gasLimit); + const timestamp = Date.now(); - const [wrapStatus, setWrapStatus] = useState(WrapStatus.INITIAL); - const [txHash, setTxHash] = useState(); + const symbol = isNativeAsset(currentAssetData) + ? currentAssetData.wrappedAssetSymbol + : currentAsset || ''; - const receiveAmount = amount.gt(minimum) ? amount.minus(bridgeFee) : BIG_ZERO; + const result = await transferEVMAsset(amount, currentAssetData); - const transfer = useCallback( - async (customGasSettings: CustomGasSettings) => { - if (!currentAssetData) return Promise.reject(); - - const timestamp = Date.now(); - const symbol = isNativeAsset(currentAssetData) - ? currentAssetData.wrappedAssetSymbol - : currentAsset || ''; - - const result = await transferAsset( - amount, - currentAssetData, - customGasSettings, - setWrapStatus, - setTxHash - ); - - setTransactionDetails({ - tokenSymbol: symbol, - amount, - }); - createBridgeTransaction({ - sourceChain: Blockchain.ETHEREUM, - sourceTxHash: result.hash, - sourceStartedAt: timestamp, - targetChain: Blockchain.AVALANCHE, - amount, - symbol, - }); - - return result.hash; - }, - [ + setTransactionDetails({ + tokenSymbol: symbol, amount, - currentAssetData, - createBridgeTransaction, - currentAsset, - setTransactionDetails, - transferAsset, - ] - ); + }); + createBridgeTransaction({ + sourceChain: Blockchain.ETHEREUM, + sourceTxHash: result.hash, + sourceStartedAt: timestamp, + targetChain: Blockchain.AVALANCHE, + amount, + symbol, + }); + + return result.hash; + }, [ + amount, + currentAssetData, + createBridgeTransaction, + currentAsset, + setTransactionDetails, + transferEVMAsset, + ]); return { sourceBalance, assetsWithBalances, - hasEnoughForNetworkFee, receiveAmount, maximum, price: sourceBalance?.price, - wrapStatus, - txHash, + estimateGas, transfer, }; } diff --git a/src/pages/Bridge/hooks/useUnifiedBridge.ts b/src/pages/Bridge/hooks/useUnifiedBridge.ts index 627d48b74..47bf55fbf 100644 --- a/src/pages/Bridge/hooks/useUnifiedBridge.ts +++ b/src/pages/Bridge/hooks/useUnifiedBridge.ts @@ -15,11 +15,8 @@ import { useNetworkContext } from '@src/contexts/NetworkProvider'; import { useAssetBalancesEVM } from './useAssetBalancesEVM'; import { BridgeAdapter } from './useBridge'; -import { useHasEnoughForGas } from './useHasEnoughtForGas'; import { isUnifiedBridgeAsset } from '../utils/isUnifiedBridgeAsset'; -import { BridgeStepDetails } from '@avalabs/bridge-unified'; import { useAnalyticsContext } from '@src/contexts/AnalyticsProvider'; -import { CustomGasSettings } from '@src/background/services/bridge/models'; /** * Hook for when the Unified Bridge SDK can handle the transfer @@ -27,8 +24,7 @@ import { CustomGasSettings } from '@src/background/services/bridge/models'; export function useUnifiedBridge( amount: Big, targetChainId: number, - currentAssetAddress?: string, - gasSetting?: CustomGasSettings + currentAssetAddress?: string ): BridgeAdapter { const { currentAsset, @@ -46,7 +42,6 @@ export function useUnifiedBridge( const [maximum, setMaximum] = useState(); const [minimum, setMinimum] = useState(); const [bridgeFee, setBridgeFee] = useState(); - const [bridgeStep, setBridgeStep] = useState(); const isEthereum = currentBlockchain === Blockchain.ETHEREUM; const { assetsWithBalances } = useAssetBalancesEVM( @@ -62,10 +57,6 @@ export function useUnifiedBridge( }); }, [network, assetsWithBalances, currentAssetAddress, currentAsset]); - const hasEnoughForNetworkFee = useHasEnoughForGas(gasSetting?.gasLimit); - - const [txHash, setTxHash] = useState(); - useEffect(() => { if (!maximum && sourceBalance?.balance) { setMaximum(sourceBalance.balance); @@ -139,67 +130,58 @@ export function useUnifiedBridge( [estimateTransferGas, targetChainId] ); - const transfer = useCallback( - async (customGasSettings: CustomGasSettings) => { - capture('unifedBridgeTransferStarted', { - bridgeType: 'CCTP', - sourceBlockchain: currentBlockchain, - targetBlockchain, - }); - - if (!currentAsset) { - throw new Error('No asset chosen'); - } + const transfer = useCallback(async () => { + capture('unifedBridgeTransferStarted', { + bridgeType: 'CCTP', + sourceBlockchain: currentBlockchain, + targetBlockchain, + }); - if (!currentAssetData) { - throw new Error('No asset data'); - } + if (!currentAsset) { + throw new Error('No asset chosen'); + } - const symbol = isNativeAsset(currentAssetData) - ? currentAssetData.wrappedAssetSymbol - : currentAsset || ''; + if (!currentAssetData) { + throw new Error('No asset data'); + } - const hash = await transferAsset( - currentAsset, - bigToBigInt(amount, currentAssetData.denomination), - targetChainId, - setBridgeStep, - customGasSettings - ); + const symbol = isNativeAsset(currentAssetData) + ? currentAssetData.wrappedAssetSymbol + : currentAsset || ''; - setTxHash(hash); - setTransactionDetails({ - tokenSymbol: symbol, - amount, - }); + const hash = await transferAsset( + currentAsset, + bigToBigInt(amount, currentAssetData.denomination), + targetChainId + ); - return hash; - }, - [ + setTransactionDetails({ + tokenSymbol: symbol, amount, - currentAssetData, - currentAsset, - setTransactionDetails, - transferAsset, - targetChainId, - capture, - currentBlockchain, - targetBlockchain, - ] - ); + }); + + return hash; + }, [ + amount, + currentAssetData, + currentAsset, + setTransactionDetails, + transferAsset, + targetChainId, + capture, + currentBlockchain, + targetBlockchain, + ]); return { sourceBalance, - bridgeStep, estimateGas, assetsWithBalances, - hasEnoughForNetworkFee, receiveAmount, bridgeFee, maximum, minimum, price: sourceBalance?.price, - txHash, transfer, }; } diff --git a/src/pages/SignTransaction/SignTransaction.tsx b/src/pages/SignTransaction/SignTransaction.tsx index a1343495b..1fb9dcd78 100644 --- a/src/pages/SignTransaction/SignTransaction.tsx +++ b/src/pages/SignTransaction/SignTransaction.tsx @@ -1,4 +1,7 @@ import { + Alert, + AlertContent, + AlertTitle, Box, Button, CodeIcon, @@ -164,6 +167,8 @@ export function SignTransactionPage() { const isTransactionSuspicious = transaction.displayData.displayValues.isSuspicious; + const { contextInformation } = transaction.displayData.displayOptions ?? {}; + return ( <> {header} )} + {contextInformation && ( + + + {contextInformation.title} + {contextInformation.notice && ( + {contextInformation.notice} + )} + + + )} {/* Actions */} { const { t } = useTranslation(); + if (transaction?.displayOptions?.customApprovalScreenTitle) { + return transaction.displayOptions.customApprovalScreenTitle; + } + const transactionTypes = transaction?.displayValues?.actions.map( (a) => a.type ); diff --git a/src/pages/Wallet/components/History/components/InProgressBridge/InProgressBridgeActivityCard.tsx b/src/pages/Wallet/components/History/components/InProgressBridge/InProgressBridgeActivityCard.tsx index ea86639ef..1b9ad6c63 100644 --- a/src/pages/Wallet/components/History/components/InProgressBridge/InProgressBridgeActivityCard.tsx +++ b/src/pages/Wallet/components/History/components/InProgressBridge/InProgressBridgeActivityCard.tsx @@ -12,7 +12,10 @@ import { } from '@avalabs/core-k2-components'; import { useBridgeContext } from '@src/contexts/BridgeProvider'; import { useNetworkContext } from '@src/contexts/NetworkProvider'; -import { blockchainToNetwork } from '@src/pages/Bridge/utils/blockchainConversion'; +import { + blockchainToNetwork, + networkToBlockchain, +} from '@src/pages/Bridge/utils/blockchainConversion'; import { getExplorerAddressByNetwork } from '@src/utils/getExplorerAddress'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -209,8 +212,13 @@ export function InProgressBridgeActivityCard({ p: 0, }} onClick={() => { + const chainName = + typeof tx.sourceChain === 'string' + ? tx.sourceChain + : networkToBlockchain(tx.sourceChain); + history.push( - `/bridge/transaction-status/${tx.sourceChain}/${tx.sourceTxHash}/${tx.sourceStartedAt}` + `/bridge/transaction-status/${chainName}/${tx.sourceTxHash}/${tx.sourceStartedAt}` ); }} > diff --git a/src/utils/handleTxOutcome.ts b/src/utils/handleTxOutcome.ts index 0455c6dac..8972f4006 100644 --- a/src/utils/handleTxOutcome.ts +++ b/src/utils/handleTxOutcome.ts @@ -27,7 +27,7 @@ export async function handleTxOutcome(txRequestPromise: Promise): Promise< }; } catch (err) { return { - isApproved: !!err || !isUserRejectionError(err), + isApproved: !isUserRejectionError(err), hasError: true, error: err, }; diff --git a/src/utils/updateIfDifferent.ts b/src/utils/updateIfDifferent.ts new file mode 100644 index 000000000..b1aac292f --- /dev/null +++ b/src/utils/updateIfDifferent.ts @@ -0,0 +1,33 @@ +import { isEqual } from 'lodash'; +import type { Dispatch, SetStateAction } from 'react'; + +type ExtractTypeFromStateSetter = Type extends Dispatch< + SetStateAction +> + ? { StateType: T } + : never; + +/** + * @param newValue New value being proposed to the state setter + * @returns A callback to be passed to React's SetState functions. + * It will only update the state if the actual value (not the reference) change. + * Use it to prevent unnecessary re-renders. + */ +export function updateIfDifferent< + StateSetter extends Dispatch> +>( + setStateFn: StateSetter, + newState: ExtractTypeFromStateSetter['StateType'] +) { + setStateFn((prevState) => { + if (newState === prevState) { + return prevState; + } + + if (isEqual(prevState, newState)) { + return prevState; + } + + return newState; + }); +} diff --git a/src/utils/useWindowGetsClosedOrHidden.ts b/src/utils/useWindowGetsClosedOrHidden.ts index 5111bcb23..c81698699 100644 --- a/src/utils/useWindowGetsClosedOrHidden.ts +++ b/src/utils/useWindowGetsClosedOrHidden.ts @@ -1,7 +1,15 @@ +import { + ContextContainer, + useIsSpecificContextContainer, +} from '@src/hooks/useIsSpecificContextContainer'; import { useEffect } from 'react'; import { filter, first, fromEventPattern, merge } from 'rxjs'; export function useWindowGetsClosedOrHidden(cancelHandler: () => void) { + const isConfirmPopup = useIsSpecificContextContainer( + ContextContainer.CONFIRM + ); + useEffect(() => { const subscription = merge( fromEventPattern( @@ -27,11 +35,14 @@ export function useWindowGetsClosedOrHidden(cancelHandler: () => void) { ) .pipe(first()) .subscribe(() => { - cancelHandler(); + // Only close for popup windows. The extension UI should not react this way. + if (isConfirmPopup) { + cancelHandler(); + } }); return () => { - subscription.unsubscribe(); + subscription?.unsubscribe(); }; - }, [cancelHandler]); + }, [cancelHandler, isConfirmPopup]); }