diff --git a/.env.defaults b/.env.defaults index 111fe231f2..161bd5cce2 100644 --- a/.env.defaults +++ b/.env.defaults @@ -4,9 +4,7 @@ BLOCKNATIVE_API_KEY="f60816ff-da02-463f-87a6-67a09c6d53fa" READ_REDUX_CACHE=true WRITE_REDUX_CACHE=true HIDE_EARN_PAGE=true -HIDE_SEND_BUTTON=false HIDE_ADD_SEED=false -HIDE_SWAP=false HIDE_IMPORT_DERIVATION_PATH=true HIDE_CREATE_PHRASE=false HIDE_IMPORT_LEDGER=false @@ -18,4 +16,4 @@ USE_MAINNET_FORK=false MAINNET_FORK_URL="http://127.0.0.1:8545" MAINNET_FORK_CHAIN_ID=1337 FILE_DIRECTORY_IPFS_HASH="QmYwYkRdYMBCtMqbimgaXjf7UL4RUb5zBQu4TDE67oZbq5" -PART_GLOSSARY_IPFS_HASH="bafybeibytsozn7qsvqgecogv5urg5en34r7v3zxo326vacumi56ckah5b4" \ No newline at end of file +PART_GLOSSARY_IPFS_HASH="bafybeibytsozn7qsvqgecogv5urg5en34r7v3zxo326vacumi56ckah5b4" diff --git a/background/features/features.ts b/background/features/features.ts index bdc0692fdf..6db6ccb095 100644 --- a/background/features/features.ts +++ b/background/features/features.ts @@ -1,7 +1,5 @@ export const HIDE_ADD_SEED = process.env.HIDE_ADD_SEED === "true" -export const HIDE_SEND_BUTTON = process.env.HIDE_SEND_BUTTON === "true" export const HIDE_EARN_PAGE = process.env.HIDE_EARN_PAGE === "true" -export const HIDE_SWAP = process.env.HIDE_SWAP === "true" export const HIDE_IMPORT_DERIVATION_PATH = process.env.HIDE_IMPORT_DERIVATION_PATH === "true" export const HIDE_CREATE_PHRASE = process.env.HIDE_CREATE_PHRASE === "true" diff --git a/background/lib/logger.ts b/background/lib/logger.ts index 60cae36cca..238d5289b5 100644 --- a/background/lib/logger.ts +++ b/background/lib/logger.ts @@ -81,7 +81,6 @@ function logLabelFromStackEntry( return stackEntry .split(WEBKIT_GECKO_DELIMITER)[0] .split(GECKO_MARKER) - .reverse() .filter((item) => item.replace(/(?:promise)? { // The version of persisted Redux state the extension is expecting. Any previous // state without this version, or with a lower version, ought to be migrated. -const REDUX_STATE_VERSION = 5 +const REDUX_STATE_VERSION = 6 type Migration = (prevState: Record) => Record @@ -210,6 +210,13 @@ const REDUX_MIGRATIONS: { [version: number]: Migration } = { const { ...newState } = prevState newState.keyrings.keyringMetadata = {} + return newState + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 6: (prevState: any) => { + const { ...newState } = prevState + newState.ledger.isArbitraryDataSigningEnabled = false + return newState }, } @@ -731,14 +738,17 @@ export default class Main extends BaseService { await this.chainService.populateEVMTransactionNonce(transaction) try { - const signedTx = await this.keyringService.signTransaction( - { - address: transaction.from, - network: this.chainService.ethereumNetwork, - }, - transactionWithNonce + const signedTransactionResult = + await this.keyringService.signTransaction( + { + address: transaction.from, + network: this.chainService.ethereumNetwork, + }, + transactionWithNonce + ) + await this.store.dispatch( + transactionSigned(signedTransactionResult) ) - this.store.dispatch(signed(signedTx)) } catch (exception) { logger.error( "Error signing transaction; releasing nonce", @@ -748,11 +758,11 @@ export default class Main extends BaseService { } } else { try { - const signedTx = await this.signingService.signTransaction( - transaction, - method + const signedTransactionResult = + await this.signingService.signTransaction(transaction, method) + await this.store.dispatch( + transactionSigned(signedTransactionResult) ) - this.store.dispatch(signed(signedTx)) } catch (exception) { logger.error("Error signing transaction", exception) this.store.dispatch( @@ -1015,9 +1025,11 @@ export default class Main extends BaseService { } } - const resolveAndClear = (signedTransaction: SignedEVMTransaction) => { + const resolveAndClear = ( + signedTransactionResult: SignedEVMTransaction + ) => { clear() - resolver(signedTransaction) + resolver(signedTransactionResult) } const rejectAndClear = () => { diff --git a/background/redux-slices/0x-swap.ts b/background/redux-slices/0x-swap.ts index 52db23d68c..417b459918 100644 --- a/background/redux-slices/0x-swap.ts +++ b/background/redux-slices/0x-swap.ts @@ -47,10 +47,6 @@ export const initialState: SwapState = { inProgressApprovalContract: undefined, } -// The magic string used by the 0x API to signify we're dealing with ETH rather -// than an ERC-20 -const ZEROX_ETH_SIGNIFIER = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" - const swapSlice = createSlice({ name: "0x-swap", initialState, @@ -132,7 +128,7 @@ const gatedHeaders: { [header: string]: string } = function build0xUrlFromSwapRequest( requestPath: string, { assets, amount, slippageTolerance, gasPrice }: SwapQuoteRequest, - additionalParameters?: Record + additionalParameters: Record ): URL { const requestUrl = new URL(`https://${zeroXApiBase}/swap/v1${requestPath}`) const tradeAmount = utils.parseUnits( @@ -165,6 +161,16 @@ function build0xUrlFromSwapRequest( ...gatedParameters, ...additionalParameters, }).forEach(([parameter, value]) => { + // Do not set buyTokenPercentageFee if swapping to ETH. Currently the 0x + // `GET /quote` endpoint does not support a `sellTokenPercentageFee` and + // errors when passing in a `buyTokenPercentageFee` when the buy token is + // ETH. + if ( + buyToken === "ETH" && + (parameter === "buyTokenPercentageFee" || parameter === "feeRecipient") + ) { + return + } requestUrl.searchParams.set(parameter, value.toString()) }) @@ -249,7 +255,7 @@ export const fetchSwapPrice = createBackgroundAsyncThunk( let needsApproval = false // If we aren't selling ETH, check whether we need an approval to swap // TODO Handle other non-ETH base assets - if (quote.sellTokenAddress !== ZEROX_ETH_SIGNIFIER) { + if (quote.allowanceTarget !== ethers.constants.AddressZero) { const assetContract = new ethers.Contract( quote.sellTokenAddress, ERC20_ABI, diff --git a/background/redux-slices/assets.ts b/background/redux-slices/assets.ts index 20ed06c5f3..849d62b9fd 100644 --- a/background/redux-slices/assets.ts +++ b/background/redux-slices/assets.ts @@ -1,7 +1,20 @@ import { createSelector, createSlice } from "@reduxjs/toolkit" -import { AnyAsset, PricePoint } from "../assets" +import { ethers } from "ethers" +import { + AnyAsset, + AnyAssetAmount, + isSmartContractFungibleAsset, + PricePoint, +} from "../assets" +import { AddressOnNetwork } from "../accounts" import { findClosestAssetIndex } from "../lib/asset-similarity" import { normalizeEVMAddress } from "../lib/utils" +import { createBackgroundAsyncThunk } from "./utils" +import { isNetworkBaseAsset } from "./utils/asset-utils" +import { getProvider } from "./utils/contract-utils" +import { sameNetwork } from "../networks" +import { ERC20_INTERFACE } from "../lib/erc20" +import logger from "../lib/logger" type SingleAssetState = AnyAsset & { prices: PricePoint[] @@ -145,6 +158,72 @@ const selectPairedAssetSymbol = ( pairedAssetSymbol: string ) => pairedAssetSymbol +/** + * Executes an asset transfer between two addresses, for a set amount. Supports + * an optional fixed gas limit. + * + * If the from address is not a writeable address in the wallet, this signature + * will not be possible. + */ +export const transferAsset = createBackgroundAsyncThunk( + "assets/transferAsset", + async ({ + fromAddressNetwork: { address: fromAddress, network: fromNetwork }, + toAddressNetwork: { address: toAddress, network: toNetwork }, + assetAmount, + gasLimit, + }: { + fromAddressNetwork: AddressOnNetwork + toAddressNetwork: AddressOnNetwork + assetAmount: AnyAssetAmount + gasLimit: bigint | undefined + }) => { + if (!sameNetwork(fromNetwork, toNetwork)) { + throw new Error("Only same-network transfers are supported for now.") + } + + const provider = getProvider() + const signer = provider.getSigner() + + if (isNetworkBaseAsset(assetAmount.asset, fromNetwork)) { + logger.debug( + `Sending ${assetAmount.amount} ${assetAmount.asset.symbol} from ` + + `${fromAddress} to ${toAddress} as a base asset transfer.` + ) + await signer.sendTransaction({ + from: fromAddress, + to: toAddress, + value: assetAmount.amount, + gasLimit, + }) + } else if (isSmartContractFungibleAsset(assetAmount.asset)) { + logger.debug( + `Sending ${assetAmount.amount} ${assetAmount.asset.symbol} from ` + + `${fromAddress} to ${toAddress} as an ERC20 transfer.` + ) + const token = new ethers.Contract( + assetAmount.asset.contractAddress, + ERC20_INTERFACE, + signer + ) + + const transactionDetails = await token.populateTransaction.transfer( + toAddress, + assetAmount.amount + ) + + await signer.sendUncheckedTransaction({ + ...transactionDetails, + gasLimit: gasLimit ?? transactionDetails.gasLimit, + }) + } else { + throw new Error( + "Only base and fungible smart contract asset transfers are supported for now." + ) + } + } +) + /** * Selects a particular asset price point given the asset symbol and the paired * asset symbol used to price it. diff --git a/background/redux-slices/transaction-construction.ts b/background/redux-slices/transaction-construction.ts index c67b26b3f6..7258e0ac6c 100644 --- a/background/redux-slices/transaction-construction.ts +++ b/background/redux-slices/transaction-construction.ts @@ -47,10 +47,7 @@ export enum NetworkFeeTypeChosen { } export type TransactionConstruction = { status: TransactionConstructionStatus - // @TODO Check if this can still be both types - transactionRequest?: - | EIP1559TransactionRequest - | EnrichedEIP1559TransactionRequest + transactionRequest?: EnrichedEIP1559TransactionRequest signedTransaction?: SignedEVMTransaction broadcastOnSign?: boolean transactionLikelyFails?: boolean @@ -106,13 +103,6 @@ export const signTransaction = createBackgroundAsyncThunk( } ) -export const broadcastSignedTransaction = createBackgroundAsyncThunk( - "transaction-construction/broadcast", - async (transaction: SignedEVMTransaction) => { - await emitter.emit("broadcastSignedTransaction", transaction) - } -) - const transactionSlice = createSlice({ name: "transaction-construction", initialState, @@ -252,6 +242,28 @@ export const { export default transactionSlice.reducer +export const broadcastSignedTransaction = createBackgroundAsyncThunk( + "transaction-construction/broadcast", + async (transaction: SignedEVMTransaction) => { + await emitter.emit("broadcastSignedTransaction", transaction) + } +) + +export const transactionSigned = createBackgroundAsyncThunk( + "transaction-construction/transaction-signed", + async (transaction: SignedEVMTransaction, { dispatch, getState }) => { + await dispatch(signed(transaction)) + + const { transactionConstruction } = getState() as { + transactionConstruction: TransactionConstruction + } + + if (transactionConstruction.broadcastOnSign ?? false) { + await dispatch(broadcastSignedTransaction(transaction)) + } + } +) + export const rejectTransactionSignature = createBackgroundAsyncThunk( "transaction-construction/reject", async (_, { dispatch }) => { @@ -313,6 +325,12 @@ export const selectTransactionData = createSelector( (transactionRequestData) => transactionRequestData ) +export const selectIsTransactionPendingSignature = createSelector( + (state: { transactionConstruction: TransactionConstruction }) => + state.transactionConstruction.status, + (status) => status === "loaded" || status === "pending" +) + export const selectIsTransactionLoaded = createSelector( (state: { transactionConstruction: TransactionConstruction }) => state.transactionConstruction.status, diff --git a/background/redux-slices/utils/asset-utils.ts b/background/redux-slices/utils/asset-utils.ts index 3c6dec4753..1612ae9fb2 100644 --- a/background/redux-slices/utils/asset-utils.ts +++ b/background/redux-slices/utils/asset-utils.ts @@ -7,8 +7,10 @@ import { PricePoint, FungibleAsset, UnitPricePoint, + AnyAsset, } from "../../assets" import { fromFixedPointNumber } from "../../lib/fixed-point" +import { AnyNetwork } from "../../networks" /** * Adds user-specific amounts based on preferences. This is the combination of @@ -33,6 +35,28 @@ export type AssetDecimalAmount = { localizedDecimalAmount: string } +/** + * Given an asset and a network, determines whether the given asset is the base + * asset for the given network. Used to special-case transactions that should + * work differently for base assets vs, for example, smart contract assets. + * + * @param asset The asset that could be a base asset for a network. + * @param network The network whose base asset `asset` should be checked against. + * + * @return True if the passed asset is the base asset for the passed network. + */ +export function isNetworkBaseAsset( + asset: AnyAsset, + network: AnyNetwork +): boolean { + return ( + !("homeNetwork" in asset) && + "family" in network && + network.family === "EVM" && + asset.symbol === network.baseAsset.symbol + ) +} + /** * Given an asset symbol, price as a JavaScript number, and a number of desired * decimals during formatting, format the price in a localized way as a diff --git a/background/services/ledger/index.ts b/background/services/ledger/index.ts index 4c921f3e44..c8b447b051 100644 --- a/background/services/ledger/index.ts +++ b/background/services/ledger/index.ts @@ -227,22 +227,23 @@ export default class LedgerService extends BaseService { this.onConnection(event.device.productId) } - #handleUSBDisconnect = async (event: USBConnectionEvent): Promise => { - this.emitter.emit( - "usbDeviceCount", - (await navigator.usb.getDevices()).length - ) - if (!this.#currentLedgerId) { - return - } + #handleUSBDisconnect = + async (/* event: USBConnectionEvent */): Promise => { + this.emitter.emit( + "usbDeviceCount", + (await navigator.usb.getDevices()).length + ) + if (!this.#currentLedgerId) { + return + } - this.emitter.emit("disconnected", { - id: this.#currentLedgerId, - type: LedgerType.LEDGER_NANO_S, - }) + this.emitter.emit("disconnected", { + id: this.#currentLedgerId, + type: LedgerType.LEDGER_NANO_S, + }) - this.#currentLedgerId = null - } + this.#currentLedgerId = null + } protected async internalStartService(): Promise { await super.internalStartService() // Not needed, but better to stick to the patterns diff --git a/background/tests/keyring-integration.test.ts b/background/tests/keyring-integration.test.ts index ac037ee5be..095f488bd3 100644 --- a/background/tests/keyring-integration.test.ts +++ b/background/tests/keyring-integration.test.ts @@ -79,7 +79,7 @@ function expectBase64String( const mockAlarms = (mock: MockzillaDeep) => { mock.alarms.create.mock(() => ({})) - mock.alarms.onAlarm.addListener.mock((_, __) => ({})) + mock.alarms.onAlarm.addListener.mock(() => ({})) } describe("KeyringService when uninitialized", () => { diff --git a/provider-bridge-shared/eip-1193.ts b/provider-bridge-shared/eip-1193.ts index c568c64fc5..af1fbcc57a 100644 --- a/provider-bridge-shared/eip-1193.ts +++ b/provider-bridge-shared/eip-1193.ts @@ -48,7 +48,7 @@ export class EIP1193Error extends Error { super(eip1193Error.message) } - toJSON() { + toJSON(): unknown { return this.eip1193Error } } diff --git a/provider-bridge-shared/runtime-typechecks.ts b/provider-bridge-shared/runtime-typechecks.ts index 38bbc16de3..2ca2909190 100644 --- a/provider-bridge-shared/runtime-typechecks.ts +++ b/provider-bridge-shared/runtime-typechecks.ts @@ -7,7 +7,7 @@ import { TallyAccountPayload, } from "./types" -export function getType(arg: unknown) { +export function getType(arg: unknown): string { return Object.prototype.toString.call(arg).slice("[object ".length, -1) } diff --git a/ui/components/BrowserTab/BrowserTabContainer.tsx b/ui/components/BrowserTab/BrowserTabContainer.tsx index 16610fc173..e131698040 100644 --- a/ui/components/BrowserTab/BrowserTabContainer.tsx +++ b/ui/components/BrowserTab/BrowserTabContainer.tsx @@ -11,7 +11,7 @@ export default function BrowserTabContainer({