This repository has been archived by the owner on Feb 26, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 681
fix: nonce generation race conditions #3498
Draft
MicaiahReid
wants to merge
21
commits into
develop
Choose a base branch
from
generate-the-right-nonce-or-else
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
6514c0b
add test to expose nonce generation race condition
MicaiahReid f2dcffe
rename to pending cause wtf were we thinking?
MicaiahReid f9c43f2
check inProgress queue for next nonce
MicaiahReid 05a8b30
fix error message
MicaiahReid 0a34708
rearrange tests a bit
MicaiahReid 43025ce
add tests for nonce generation
MicaiahReid c2551d5
fix logic for finding highest nonce by origin
MicaiahReid e7547ad
queue transactions using semaphore
MicaiahReid be6b646
remove logging
MicaiahReid ab14e24
only reference private variable once
MicaiahReid a9b2de4
add tests for some race conditions
MicaiahReid 38d6d02
fix typo in comment
MicaiahReid eb0eed4
cache account balance with inProgress txs
MicaiahReid eb37525
add comments
MicaiahReid be16adf
jsdoc comments
MicaiahReid 82bd7b5
rearranging
MicaiahReid 37af237
remove logging
MicaiahReid 7127d38
remove unneeded var
MicaiahReid 5c11677
add comment
MicaiahReid 17fab06
add type
MicaiahReid 0b5fa5b
add comments to prepareTransaction
MicaiahReid File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,12 @@ | ||
import { TypedTransaction } from "@ganache/ethereum-transaction"; | ||
import { Heap } from "@ganache/utils"; | ||
import { Heap, Quantity } from "@ganache/utils"; | ||
|
||
export type InProgressData = { | ||
transaction: TypedTransaction; | ||
originBalance: Quantity; | ||
}; | ||
|
||
export type Executables = { | ||
inProgress: Set<TypedTransaction>; | ||
inProgress: Map<string, Set<InProgressData>>; | ||
pending: Map<string, Heap<TypedTransaction>>; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,7 +12,8 @@ import { | |
INSUFFICIENT_FUNDS | ||
} from "@ganache/ethereum-utils"; | ||
import { EthereumInternalOptions } from "@ganache/ethereum-options"; | ||
import { Executables } from "./miner/executables"; | ||
import { Executables, InProgressData } from "./miner/executables"; | ||
import Semaphore from "semaphore"; | ||
import { TypedTransaction } from "@ganache/ethereum-transaction"; | ||
|
||
/** | ||
|
@@ -39,7 +40,7 @@ function shouldReplace( | |
} | ||
|
||
// if the transaction being replaced is in the middle of being mined, we can't | ||
// replpace it so let's back out early | ||
// replace it so let's back out early | ||
if (replacee.locked) { | ||
throw new CodedError( | ||
TRANSACTION_LOCKED, | ||
|
@@ -77,6 +78,20 @@ function shouldReplace( | |
} | ||
} | ||
|
||
/** | ||
* Throws insufficient funds error if `balance` < `cost`. | ||
* @param cost The transaction cost. | ||
* @param balance The account's balance. | ||
*/ | ||
function validateSufficientFunds(cost: bigint, balance: bigint) { | ||
if (balance < cost) { | ||
throw new CodedError( | ||
INSUFFICIENT_FUNDS, | ||
JsonRpcErrorCode.TRANSACTION_REJECTED | ||
); | ||
} | ||
} | ||
|
||
function byNonce(values: TypedTransaction[], a: number, b: number) { | ||
return ( | ||
(values[b].nonce.toBigInt() || 0n) > (values[a].nonce.toBigInt() || 0n) | ||
|
@@ -118,6 +133,7 @@ export default class TransactionPool extends Emittery<{ drain: undefined }> { | |
#priceBump: bigint; | ||
|
||
#blockchain: Blockchain; | ||
#originsQueue: Map<string, Semaphore> = new Map(); | ||
constructor( | ||
options: EthereumInternalOptions["miner"], | ||
blockchain: Blockchain, | ||
|
@@ -130,14 +146,45 @@ export default class TransactionPool extends Emittery<{ drain: undefined }> { | |
this.#priceBump = options.priceBump; | ||
} | ||
public readonly executables: Executables = { | ||
inProgress: new Set(), | ||
inProgress: new Map(), | ||
pending: new Map() | ||
}; | ||
public readonly origins: Map<string, Heap<TypedTransaction>>; | ||
readonly #accountPromises = new Map< | ||
string, | ||
Promise<{ balance: Quantity; nonce: Quantity }> | ||
>(); | ||
|
||
/** | ||
* Queues transactions to be inserted into the pool via `_prepareTransaction` | ||
* such that unique-origin transactions are added to the pool concurrently and | ||
* same-origin transactions are queued in series. | ||
* @param transaction The transaction to be added to the transaction pool. | ||
* @param secretKey The optional key to sign and hash the transaction. | ||
* @returns Data that can be used to drain the queue. | ||
*/ | ||
public async prepareTransaction( | ||
transaction: TypedTransaction, | ||
secretKey?: Data | ||
) { | ||
const origin = transaction.from.toString(); | ||
const originsQueue = this.#originsQueue; | ||
let queueForOrigin: Semaphore; | ||
// each unique origin gets its own semaphore rather than them all sharing | ||
// one because we only need transactions from the same origin to be added in | ||
// series | ||
if (!(queueForOrigin = originsQueue.get(origin))) { | ||
queueForOrigin = Semaphore(1); | ||
originsQueue.set(origin, queueForOrigin); | ||
} | ||
// the semaphore will hold on `take` until previous uses have resolved | ||
await new Promise(resolve => queueForOrigin.take(resolve)); | ||
try { | ||
// check if transaction is in origins, if not add it | ||
// if so, use semaphore to call prepareTransaction | ||
// if not, just call prepare transaction | ||
return await this._prepareTransaction(transaction, secretKey); | ||
} finally { | ||
// free up the semaphore once we finish | ||
queueForOrigin.leave(); | ||
} | ||
} | ||
|
||
/** | ||
* Inserts a transaction into the pending queue, if executable, or future pool | ||
|
@@ -147,7 +194,7 @@ export default class TransactionPool extends Emittery<{ drain: undefined }> { | |
* @param secretKey - | ||
* @returns data that can be used to drain the queue | ||
*/ | ||
public async prepareTransaction( | ||
public async _prepareTransaction( | ||
transaction: TypedTransaction, | ||
secretKey?: Data | ||
) { | ||
|
@@ -166,18 +213,6 @@ export default class TransactionPool extends Emittery<{ drain: undefined }> { | |
|
||
const origin = from.toString(); | ||
|
||
// We await the `transactorNoncePromise` async request to ensure we process | ||
// transactions in FIFO order *by account*. We look up accounts because | ||
// ganache fills in missing nonces automatically, and we need to do it in | ||
// order. | ||
// The trick here is that we might actually get the next nonce from the | ||
// account's pending executable transactions, not the account... | ||
// But another transaction might currently be getting the nonce from the | ||
// account, if it is, we need to wait for it to be done doing that. Hence: | ||
let transactorPromise = this.#accountPromises.get(origin); | ||
if (transactorPromise) { | ||
await transactorPromise; | ||
} | ||
// if the user called sendTransaction or sendRawTransaction, effectiveGasPrice | ||
// hasn't been set yet on the tx. calculating the effectiveGasPrice requires | ||
// the block context, so we need to set it outside of the transaction. this | ||
|
@@ -192,47 +227,16 @@ export default class TransactionPool extends Emittery<{ drain: undefined }> { | |
transaction.updateEffectiveGasPrice(baseFeePerGas); | ||
} | ||
|
||
// we should _probably_ cache `highestNonce`, but it's actually a really hard thing to cache as the current highest | ||
// nonce might be invalidated (like if the sender doesn't have enough funds), so we'd have to go back to the previous | ||
// highest nonce... but what if that previous highest nonce was also invalidated?! we have to go back to the... you | ||
// get the picture. | ||
// So... we currently do things sub-optimally: | ||
// if we currently have txs in `executableOriginTransactions`, we iterate over them to find the highest nonce | ||
// and use that. Otherwise, we just fetch it from the database. | ||
// Beware! There might still be race conditions here: | ||
// * if the highest tx executes, which causes it to be removed from the `executableOriginTransactions` heap, | ||
// then a new tx comes in _before_ the block is persisted to the database, the nonce might be of the second | ||
// tx would be too low. | ||
// * rough idea for a fix: transactions have a `finalize` method that is called _after_ the tx is saved. Maybe | ||
// when tx's are executed their nonce is moved to a `highNonceByOrigin` map? We'd check this map in addition to the | ||
// `executableOriginTransactions` map, always taking the highest of the two. | ||
let highestNonce = 0n; | ||
|
||
if (!transactorPromise) { | ||
transactorPromise = this.#blockchain.accounts.getNonceAndBalance(from); | ||
this.#accountPromises.set(origin, transactorPromise); | ||
transactorPromise.then(() => { | ||
this.#accountPromises.delete(origin); | ||
}); | ||
} | ||
const transactor = await transactorPromise; | ||
|
||
const cost = | ||
const transactionCost = | ||
transaction.gas.toBigInt() * transaction.maxGasPrice().toBigInt() + | ||
transaction.value.toBigInt(); | ||
if (transactor.balance.toBigInt() < cost) { | ||
throw new CodedError( | ||
INSUFFICIENT_FUNDS, | ||
JsonRpcErrorCode.TRANSACTION_REJECTED | ||
); | ||
} | ||
|
||
const origins = this.origins; | ||
const queuedOriginTransactions = origins.get(origin); | ||
|
||
let transactionPlacement = TriageOption.FutureQueue; | ||
const executables = this.executables.pending; | ||
let executableOriginTransactions = executables.get(origin); | ||
const { pending } = this.executables; | ||
let executableOriginTransactions = pending.get(origin); | ||
|
||
const priceBump = this.#priceBump; | ||
let length: number; | ||
|
@@ -243,6 +247,15 @@ export default class TransactionPool extends Emittery<{ drain: undefined }> { | |
// check if a transaction with the same nonce is in the origin's | ||
// executables queue already. Replace the matching transaction or throw this | ||
// new transaction away as necessary. | ||
|
||
// we should _probably_ cache `highestNonce`, but it's actually a really hard thing to cache as the current highest | ||
// nonce might be invalidated (like if the sender doesn't have enough funds), so we'd have to go back to the previous | ||
// highest nonce... but what if that previous highest nonce was also invalidated?! we have to go back to the... you | ||
// get the picture. | ||
// rough idea for a fix: transactions have a `finalize` method that is called _after_ the tx is saved. Maybe | ||
// when tx's are executed their nonce is moved to a `highNonceByOrigin` map? We'd check this map in addition to the | ||
// `executableOriginTransactions` map, always taking the highest of the two. | ||
let highestNonce = 0n; | ||
const pendingArray = executableOriginTransactions.array; | ||
// Notice: we're iterating over the raw heap array, which isn't | ||
// necessarily sorted | ||
|
@@ -280,23 +293,51 @@ export default class TransactionPool extends Emittery<{ drain: undefined }> { | |
// origin's heap we are executable. | ||
transactionPlacement = TriageOption.Executable; | ||
} | ||
// we've gotten the account's nonce the synchronous way (by looking at | ||
// the pending queue), but we still need to look up the account's balance | ||
const { balance } = await this.#blockchain.accounts.getNonceAndBalance( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the case that we get the nonce from the pending transaction pool, we still need to fetch the account's balance. I've moved this to be after we find the nonce so that finding the nonce is all synchronous. |
||
from | ||
); | ||
validateSufficientFunds(transactionCost, balance.toBigInt()); | ||
} else { | ||
// since we don't have any executable transactions at the moment, we need | ||
// to find our nonce from the account itself... | ||
const transactorNonce = transactor.nonce.toBigInt(); | ||
// we don't have any pending executable transactions, but we could have | ||
// some inProgress executable transactions | ||
const { | ||
transaction: latestInProgressTransaction, | ||
originBalance: balanceAfterLatestInProgressTransaction | ||
} = this.#getLatestInProgressFromOrigin(origin); | ||
|
||
const hasInProgressFromOrigin = latestInProgressTransaction !== null; | ||
|
||
let balance: bigint; | ||
let effectiveNonce: bigint; | ||
if (hasInProgressFromOrigin) { | ||
const highestInProgressNonce = latestInProgressTransaction.nonce; | ||
effectiveNonce = highestInProgressNonce.toBigInt() + 1n; | ||
balance = balanceAfterLatestInProgressTransaction.toBigInt(); | ||
} else { | ||
// if we don't have in progress transactions either, we'll need to find | ||
// our nonce from the account itself | ||
const transactor = await this.#blockchain.accounts.getNonceAndBalance( | ||
from | ||
); | ||
effectiveNonce = transactor.nonce.toBigInt(); | ||
balance = transactor.balance.toBigInt(); | ||
} | ||
|
||
validateSufficientFunds(transactionCost, balance); | ||
|
||
if (txNonce === void 0) { | ||
// if we don't have a transactionNonce, just use the account's next | ||
// nonce and mark as executable | ||
txNonce = transactorNonce ? transactorNonce : 0n; | ||
txNonce = effectiveNonce; | ||
transaction.nonce = Quantity.from(txNonce); | ||
transactionPlacement = TriageOption.Executable; | ||
} else if (txNonce < transactorNonce) { | ||
} else if (txNonce < effectiveNonce) { | ||
// it's an error if the transaction's nonce is <= the persisted nonce | ||
throw new CodedError( | ||
`the tx doesn't have the correct nonce. account has nonce of: ${transactorNonce} tx has nonce of: ${txNonce}`, | ||
`the tx doesn't have the correct nonce. account has nonce of: ${effectiveNonce} tx has nonce of: ${txNonce}`, | ||
JsonRpcErrorCode.INVALID_INPUT | ||
); | ||
} else if (txNonce === transactorNonce) { | ||
} else if (txNonce === effectiveNonce) { | ||
transactionPlacement = TriageOption.Executable; | ||
} | ||
} | ||
|
@@ -352,7 +393,7 @@ export default class TransactionPool extends Emittery<{ drain: undefined }> { | |
} else { | ||
// if we don't yet have an executables queue for this origin make one now | ||
executableOriginTransactions = Heap.from(transaction, byNonce); | ||
executables.set(origin, executableOriginTransactions); | ||
pending.set(origin, executableOriginTransactions); | ||
} | ||
|
||
// Now we need to drain any queued transactions that were previously | ||
|
@@ -405,7 +446,6 @@ export default class TransactionPool extends Emittery<{ drain: undefined }> { | |
|
||
public clear() { | ||
this.origins.clear(); | ||
this.#accountPromises.clear(); | ||
this.executables.pending.clear(); | ||
} | ||
|
||
|
@@ -445,11 +485,14 @@ export default class TransactionPool extends Emittery<{ drain: undefined }> { | |
} | ||
|
||
// and finally transactions that have just been processed, but not yet saved | ||
for (let tx of inProgress) { | ||
if (tx.hash.toBuffer().equals(transactionHash)) { | ||
return tx; | ||
for (const [_, originData] of inProgress) | ||
if (originData) { | ||
for (let { transaction } of originData) { | ||
if (transaction.hash.toBuffer().equals(transactionHash)) { | ||
return transaction; | ||
} | ||
} | ||
} | ||
} | ||
return null; | ||
} | ||
|
||
|
@@ -476,4 +519,34 @@ export default class TransactionPool extends Emittery<{ drain: undefined }> { | |
|
||
return null; | ||
}; | ||
|
||
/** | ||
* Searches all in progress transactions from the specified origin for the | ||
* transaction with the highest nonce. | ||
* @param origin The origin to search. | ||
* @returns The in progress transaction with the highest nonce from the | ||
* specified origin, along with the account's balance after running that | ||
* transaction. | ||
*/ | ||
readonly #getLatestInProgressFromOrigin = (origin: string) => { | ||
let highestNonceData: InProgressData = { | ||
transaction: null, | ||
originBalance: null | ||
}; | ||
let highestNonce: bigint = null; | ||
const originData = this.executables.inProgress.get(origin); | ||
if (originData) { | ||
for (const inProgressData of originData) { | ||
const { transaction } = inProgressData; | ||
if ( | ||
transaction.from.toString() === origin && | ||
(highestNonce === null || transaction.nonce.toBigInt() > highestNonce) | ||
) { | ||
highestNonce = transaction.nonce.toBigInt(); | ||
highestNonceData = inProgressData; | ||
} | ||
} | ||
} | ||
return highestNonceData; | ||
}; | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note: I think we could use the semaphore's
available()
function to see if there's any transaction waiting to be prepared in this semaphore. If not, we could dispose of this semaphore. But then, if we get another transaction from that origin we have to make a whole new semaphore for it.So currently we don't drain the
originsQueue
. I'm interested in hearing y'all's thoughts on if we should drain it.It's easy to imagine someone sending a transaction at regular intervals from the same origin, and this interval being slower than it takes to queue up the previous transaction. In that case, we'd be making a new semaphore for every transaction, which is why I decided against draining.
However, someone could also send a bunch of transactions, each from a unique origin. In that case, this
originsQueue
will just keep getting wider and wider without taking advantage of its purpose.