Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Transaction replacement - Allow speeding up transactions #3456

Closed
wants to merge 23 commits into from
Closed
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b7bc255
Unsubscribe to network on custom chain removal
hyphenized May 30, 2023
e22833b
Skip managing nonces when there's a replacement transaction
hyphenized Jun 6, 2023
1ab9195
Add feature flag
hyphenized Jun 6, 2023
3c9aac5
Refactor activity details / poll updates on activity details
hyphenized Jun 8, 2023
53ae846
Allow sending replacement transactions from activities
hyphenized Jun 8, 2023
44c1a54
Fix check for missing gas prices
hyphenized Jun 9, 2023
a83cf53
Remove duplicate logic
hyphenized Jun 12, 2023
d33f857
Refactor & rename type for clarity
hyphenized Jun 12, 2023
4ac4768
Add comments and fix some i18n strings
hyphenized Jun 12, 2023
2e3bee7
Add loading skeleton, missing style
hyphenized Jun 12, 2023
1b6d955
Fix slide up preserving different activity details
hyphenized Jun 16, 2023
ab858d3
Add scrolll wrap to activity details
hyphenized Jun 19, 2023
7c1cffa
Collapse long recipient names
hyphenized Jun 19, 2023
f51c723
Improve loading state for pending transactions
hyphenized Jun 21, 2023
16d4e2c
Merge branch 'main' into tx-speed-up
hyphenized Jul 5, 2023
d1dec75
Optionally pass transaction type
hyphenized Jul 5, 2023
df746bf
Prevent display of replaced transactions in activities
hyphenized Jul 10, 2023
96adea1
Restrict the snackbar from displaying when replaced transactions are …
hyphenized Jul 10, 2023
e44bc6a
Show replacement fees in signing network settings
hyphenized Jul 11, 2023
f73a481
Avoid releasing nonces for replaced and dropped transactions
hyphenized Jul 11, 2023
0efeb71
Merge branch 'main' into tx-speed-up
hyphenized Jul 11, 2023
792ab9d
Merge branch 'main' into tx-speed-up
hyphenized Jul 27, 2023
fcdd36d
Merge branch 'main' into tx-speed-up
Jul 28, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.defaults
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ SUPPORT_SWAP_QUOTE_REFRESH=false
SUPPORT_ACHIEVEMENTS_BANNER=false
SUPPORT_NFT_SEND=false
USE_MAINNET_FORK=false
SUPPORT_TRANSACTION_REPLACEMENT=false
ENABLE_UPDATED_DAPP_CONNECTIONS=true
2 changes: 2 additions & 0 deletions background/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const RuntimeFlag = {
SUPPORT_SWAP_QUOTE_REFRESH: process.env.SUPPORT_SWAP_QUOTE_REFRESH === "true",
SUPPORT_CUSTOM_NETWORKS: process.env.SUPPORT_CUSTOM_NETWORKS === "true",
SUPPORT_CUSTOM_RPCS: process.env.SUPPORT_CUSTOM_RPCS === "true",
SUPPORT_TRANSACTION_REPLACEMENT:
process.env.SUPPORT_TRANSACTION_REPLACEMENT === "true",
ENABLE_UPDATED_DAPP_CONNECTIONS:
process.env.ENABLE_UPDATED_DAPP_CONNECTIONS === "true",
} as const
Expand Down
7 changes: 4 additions & 3 deletions background/lib/gas.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Provider } from "@ethersproject/abstract-provider"
import { Block, Provider } from "@ethersproject/abstract-provider"
import { fetchJson } from "@ethersproject/web"

import logger from "./logger"
Expand Down Expand Up @@ -148,7 +148,8 @@ const getLegacyGasPrices = async (

export default async function getBlockPrices(
network: EVMNetwork,
provider: Provider
provider: Provider,
block?: Block
): Promise<BlockPrices> {
// if BlockNative is configured and we're on mainnet, prefer their gas service
if (
Expand All @@ -169,7 +170,7 @@ export default async function getBlockPrices(
}

const [currentBlock, feeData] = await Promise.all([
provider.getBlock("latest"),
block ? Promise.resolve(block) : provider.getBlock("latest"),
provider.getFeeData(),
])

Expand Down
23 changes: 19 additions & 4 deletions background/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,8 @@ import {
AnalyticsService,
getNoopService,
} from "./services"

import { HexString, NormalizedEVMAddress } from "./types"
import { SignedTransaction } from "./networks"
import { EVMNetwork, EVMTransaction, SignedTransaction } from "./networks"
import { AccountBalance, AddressOnNetwork, NameOnNetwork } from "./accounts"
import { Eligible } from "./services/doggo/types"

Expand Down Expand Up @@ -1705,7 +1704,7 @@ export default class Main extends BaseService<never> {
return this.internalSignerService.importSigner(signerRaw)
}

async getActivityDetails(txHash: string): Promise<ActivityDetail[]> {
async getActivityDetails(txHash: string): Promise<[ActivityDetail]> {
const addressNetwork = this.store.getState().ui.selectedAccount
const transaction = await this.chainService.getTransaction(
addressNetwork.network,
Expand All @@ -1716,7 +1715,7 @@ export default class Main extends BaseService<never> {
2
)

return getActivityDetails(enrichedTransaction)
return [getActivityDetails(enrichedTransaction)]
jagodarybacka marked this conversation as resolved.
Show resolved Hide resolved
}

async connectAnalyticsService(): Promise<void> {
Expand Down Expand Up @@ -1962,6 +1961,22 @@ export default class Main extends BaseService<never> {
return this.indexingService.importCustomToken(asset)
}

async trackReplacementTransaction(
hash: string,
parentTx: string,
addressOnNetwork: AddressOnNetwork
): Promise<void> {
this.chainService.trackReplacementTransaction(
hash,
addressOnNetwork,
parentTx
)
}

async getTxData(network: EVMNetwork, hash: string): Promise<EVMTransaction> {
return this.chainService.getTransaction(network, hash)
}

private connectPopupMonitor() {
runtime.onConnect.addListener((port) => {
if (port.name !== popupMonitorPortName) return
Expand Down
12 changes: 10 additions & 2 deletions background/networks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ import type {
PartialTransactionRequestWithFrom,
} from "./services/enrichment"

/**
* Transaction status, uses the same convention as the result from eth_gettransactionreceipt
*/
export enum TxStatus {
FAIL = 0,
SUCCESS = 1,
}

/**
* Each supported network family is generally incompatible with others from a
* transaction, consensus, and/or wire format perspective.
Expand Down Expand Up @@ -244,7 +252,7 @@ export type EVMLog = {
*/
export type ConfirmedEVMTransaction = EVMTransaction & {
gasUsed: bigint
status: number
status: TxStatus
blockHash: string
blockHeight: number
logs: EVMLog[] | undefined
Expand All @@ -255,7 +263,7 @@ export type ConfirmedEVMTransaction = EVMTransaction & {
* the error if available.
*/
export type FailedConfirmationEVMTransaction = EVMTransaction & {
status: 0
status: TxStatus.FAIL
error?: string
blockHash: null
blockHeight: null
Expand Down
160 changes: 157 additions & 3 deletions background/redux-slices/activities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
normalizeEVMAddress,
sameEVMAddress,
} from "../lib/utils"
import { EVMTransaction, isEIP1559TransactionRequest } from "../networks"
import { Transaction } from "../services/chain/db"
import { EnrichedEVMTransaction } from "../services/enrichment"
import { HexString } from "../types"
Expand All @@ -20,6 +21,7 @@ import {
ActivityDetail,
INFINITE_VALUE,
} from "./utils/activities-utils"
import { getProvider } from "./utils/contract-utils"

export { Activity, ActivityDetail, INFINITE_VALUE }
export type Activities = {
Expand All @@ -28,8 +30,20 @@ export type Activities = {
}
}

const lowerCaseCmp = (a: string, b: string) =>
a.toLowerCase() === b.toLowerCase()

type TrackedReplacementTx = {
chainID: string
hash: string
parentTx: string
initiator: string
}

type ActivitiesState = {
activities: Activities
replacementTransactions: TrackedReplacementTx[]
transactionToReplace?: EVMTransaction
}

const ACTIVITIES_MAX_COUNT = 25
Expand Down Expand Up @@ -113,6 +127,7 @@ const initializeActivitiesFromTransactions = ({

const initialState: ActivitiesState = {
activities: {},
replacementTransactions: [],
}

const activitiesSlice = createSlice({
Expand All @@ -126,9 +141,11 @@ const activitiesSlice = createSlice({
}: {
payload: { transactions: Transaction[]; accounts: AddressOnNetwork[] }
}
) => ({
activities: initializeActivitiesFromTransactions(payload),
}),
) => {
immerState.activities = initializeActivitiesFromTransactions(payload)
// Clear these at extension restart
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not 100% sure about this, what if we had some transactions and then chrome decides to update the extension? Unlikely but not impossible 🤔

immerState.replacementTransactions = []
},
initializeActivitiesForAccount: (
immerState,
{
Expand Down Expand Up @@ -170,6 +187,67 @@ const activitiesSlice = createSlice({
immerState.activities[normalizeEVMAddress(address)]?.[chainID]
)
})

const { replacementTransactions } = immerState

// Check if we're already tracking the replacement tx so we can get the
// "replaced" tx and remove it if the replacement has been mined
const replacementRef = replacementTransactions.find(
(ref) =>
(lowerCaseCmp(ref.hash, transaction.hash) ||
lowerCaseCmp(ref.parentTx, transaction.hash)) &&
ref.chainID === transaction.network.chainID
)

if (replacementRef) {
const accountActivities =
immerState.activities[normalizeEVMAddress(transaction.from)][
replacementRef.chainID
]

const replacementTx = accountActivities.find((tx) =>
lowerCaseCmp(tx.hash, replacementRef.hash)
)

if (
replacementTx &&
// this new activity is the replacement tx
((lowerCaseCmp(replacementRef.hash, transaction.hash) &&
// tx has been mined
transaction.blockHash) ||
// if this new activity is the replaced tx
(lowerCaseCmp(replacementRef.parentTx, transaction.hash) &&
// replacement has been mined
replacementTx.blockHash))
) {
// drop replaced tx
immerState.activities[normalizeEVMAddress(transaction.from)][
replacementRef.chainID
] = accountActivities.filter(
({ hash }) => !lowerCaseCmp(hash, replacementRef.parentTx)
)
}
}
},
addReplacementTransaction: (
immerState,
{ payload }: { payload: TrackedReplacementTx }
) => {
const { replacementTransactions } = immerState
if (
!replacementTransactions.some(
(request) =>
request.hash === payload.hash && request.chainID === payload.chainID
)
) {
replacementTransactions.push(payload)
}
},
setTransactionToReplace(
immerState,
{ payload }: { payload: EVMTransaction | undefined }
) {
immerState.transactionToReplace = payload
},
},
})
Expand All @@ -179,6 +257,8 @@ export const {
addActivity,
removeActivities,
initializeActivitiesForAccount,
addReplacementTransaction,
setTransactionToReplace,
} = activitiesSlice.actions

export default activitiesSlice.reducer
Expand All @@ -189,3 +269,77 @@ export const fetchSelectedActivityDetails = createBackgroundAsyncThunk(
return main.getActivityDetails(activityHash)
}
)

export const speedUpTx = createBackgroundAsyncThunk(
"activities/speedupTx",
async (
tx: Transaction | EnrichedEVMTransaction,
{ dispatch, extra: { main } }
) => {
const {
hash: parentTxHash,
network,
input,
from,
type,
to,
value,
nonce,
gasLimit,
} = tx

const provider = getProvider()
const signer = provider.getSigner()
const isEIP1559Tx = isEIP1559TransactionRequest(tx)

if ((isEIP1559Tx && !tx.maxFeePerGas) || (!isEIP1559Tx && !tx.gasPrice)) {
throw new Error("Cannot speed up transaction without a valid gas price")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we not want to display a message in this case? The user does not really know what happens when something goes wrong as in this case.

Screen.Recording.2023-06-08.at.15.30.26.mov

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, another concern I have here is related to block times and chains without mempool, for example it doesn't make sense to show the option to speed up transactions on Optimism, and, on Polygon we get fast enough block times that most attempts to speed up result in the replacement transaction being dropped. Maybe we could use block times or check if we have lower than average gas fees to improve UX a bit here 🤔 .

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should make an allowlist for the speedup tx button 🤔

}

const txRequest = {
data: input || "0x",
from,
to,
value,
gasLimit,
nonce,
// if type is null, don't pass it
...(type === null ? {} : { type }),
}

if (isEIP1559Tx) {
Object.assign(txRequest, {
maxFeePerGas: tx.maxFeePerGas,
maxPriorityFeePerGas: (tx.maxPriorityFeePerGas * 120n) / 100n,
})
} else {
Object.assign(txRequest, {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
gasPrice: (tx.gasPrice! * 120n) / 100n,
})
}

dispatch(
setTransactionToReplace(await main.getTxData(network, parentTxHash))
)

const newTxHash = await signer.sendTransaction(txRequest).finally(() => {
// reset state whether the process fails or not
dispatch(setTransactionToReplace(undefined))
})

await main.trackReplacementTransaction(newTxHash.hash, parentTxHash, {
address: from,
network,
})

dispatch(
addReplacementTransaction({
hash: newTxHash.hash,
chainID: network.chainID,
parentTx: newTxHash.hash,
initiator: from,
})
)
}
)
10 changes: 10 additions & 0 deletions background/redux-slices/selectors/activitiesSelectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,13 @@ export const selectActivitesHashesForEnrichment = createSelector(
)
}
)

export const selectTrackedReplacementTransactions = createSelector(
(state: RootState) => state.activities,
(activities) => activities.replacementTransactions
)

export const selectTransactionToReplace = createSelector(
(state: RootState) => state.activities,
(activities) => activities.transactionToReplace
)
Loading
Loading