Skip to content
This repository has been archived by the owner on Feb 26, 2024. It is now read-only.

fix: if a timestamp is passed to evm_mine, subsequent blocks reflect the change in time #3317

Closed
wants to merge 17 commits into from

Conversation

jeffsmale90
Copy link
Contributor

@jeffsmale90 jeffsmale90 commented Jun 30, 2022

This fixes a regression introduced in Ganche v7, where providing a timestamp in a call to evm_mine would result in a block with the correct timestamp, but the internal time-offset would not be updated, so subsequent blocks would not reflect the change in time.

Fixes: #3265 #3271

@jeffsmale90 jeffsmale90 changed the title fix: if a timestamp is passed to evm_mine, subsequent blocks reflect the change in time. fix:#3265 fix: if a timestamp is passed to evm_mine, subsequent blocks reflect the change in time. fix: #3265 Jun 30, 2022
@jeffsmale90 jeffsmale90 marked this pull request as ready for review June 30, 2022 04:05
@davidmurdoch davidmurdoch changed the title fix: if a timestamp is passed to evm_mine, subsequent blocks reflect the change in time. fix: #3265 fix: if a timestamp is passed to evm_mine, subsequent blocks reflect the change in time Jun 30, 2022
Copy link
Contributor

@MicaiahReid MicaiahReid left a comment

Choose a reason for hiding this comment

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

Just two small changes!

BTW, I've installed this version and tested against the repro I had set up from #3265, and it fixes the issue as expected!

if (numBlocks == null) {
numBlocks = 1;
}
if (timestamp) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if (timestamp) {
if (timestamp !== null) {

I know this is a silly edge case, but what if they set the timestamp to 0? This would get skipped.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I fixed it :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

And added an explicit test case

@jeffsmale90 jeffsmale90 force-pushed the fix/evm-mine-set-time branch 3 times, most recently from 13943c9 to 3ba0cfc Compare July 13, 2022 23:12
@@ -0,0 +1,97 @@
export interface BlockTime {
Copy link
Contributor Author

@jeffsmale90 jeffsmale90 Jul 13, 2022

Choose a reason for hiding this comment

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

I've extracted the logic for block timestamp into BlockTime, which allows us to more clearly describe the behaviour, and test more thoroughly.

By settings this as a dependency, it will allow us to decouple our tests from the system clock (without deferring to timestampIncrement), for the likes of #3271 (which I'll pick up after this).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I opened PR #3389 to address #3271 but have also fixed the issue in this PR, and was able to test the issue in this PR (at least on the underlying provider, not so easy at the higher level integration test).

I figured I would leave both PRs open, so that we can get a quick fix in with #3389, but am happy to close that PR if this one goes through quickly.

@jeffsmale90
Copy link
Contributor Author

I've standardised all timestamp units to milliseconds, with the exceptions:

  • A block timestamp is obviously seconds.
  • Some API parameters are in seconds.

I ensure that all interactions below api.ts (with the exception of a block's timestamp) are ms.

…epency between start time and the value returned from reference clock causing non-zero offset. fix: #3271
}
}

constructor(getReferenceClockTime: () => number | Date, startTime: number | Date | undefined) {
Copy link
Contributor Author

@jeffsmale90 jeffsmale90 Jul 20, 2022

Choose a reason for hiding this comment

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

I made the constructor explicitly accept a startTime of value undefined, and switched the argument order to make it more intuitive. I didn't make the argument optional, because the only time that we don't pass it (except for providing a variable of value undefined) is in tests.

This allows us to instantiate a ClockBasedBlockTime with no offset, without jumping through hoops.

}

export class ClockBasedBlockTime implements BlockTime {
private _getReferenceClockTime: () => number | Date;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I changed all of the # prefixed private class fields to _ prefixed typescript privates. The former have performance implications, and I don't see the benefit until we can resolve this.

#3240 addresses this more generally.

Copy link
Member

@davidmurdoch davidmurdoch left a comment

Choose a reason for hiding this comment

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

quick partial review

}
}

constructor(startTime: number | Date, getReferenceClockTime: () => number | Date) {
Copy link
Member

Choose a reason for hiding this comment

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

Use number here (and elsewhere) instead of number | Date. The Date object isn't used internally and we are casting everywhere we used the referenced clock time. Date.now() is about a bajillion or so times faster than new Date()

Comment on lines 322 to 323
const timestampMilliseconds =
timestamp == undefined ? undefined : timestamp * 1000;
Copy link
Member

Choose a reason for hiding this comment

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

We shouldn't compute this in the loop; add this logic to the timestamp != undefined condition above.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah definitely!

@@ -297,33 +297,36 @@ export default class EthereumApi implements Api {
const blockchain = this.#blockchain;
const options = this.#options;
const vmErrorsOnRPCResponse = options.chain.vmErrorsOnRPCResponse;

let numBlocks, timestamp;
Copy link
Member

Choose a reason for hiding this comment

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

These need types.

Comment on lines 310 to 312
if (numBlocks == undefined) {
numBlocks = 1;
}
Copy link
Member

Choose a reason for hiding this comment

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

Any objection to checking this condition in the if above?

numBlocks = arg.blocks == null ? 1 : arg.blocks;

and then in the else assign numBlocks to 1.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not at all - I think that makes sense. It splits out the two use cases (args vs timestamp) nicely.


private static validateTimestamp(timestamp: number | Date) {
if (timestamp < 0) {
throw new Error(`Invalid timestamp: ${timestamp}. Value must be positive.`);
Copy link
Member

Choose a reason for hiding this comment

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

Why must the timestamp be positive? This breaks ganache --time, and evm_setTime.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As discussed separately, although a negative timestamp is valid, the block timestamp property must be a positive integer value (it is of type Quantity). Presently Ganache will accept a negative value (programatically, but not via cli), but will result in undesired block time.

I've added a comment explaining why we are validating. I don't think this should be considered a breaking change, as it's aligning Ganache with the expected behaviour, but it could cause problems for our users, so I'm happy to push that change out to milestone 8 if desired.


const startTime = this.#options.chain.time || new Date();
const blockTime =
this.#options.miner.timestampIncrement === "clock"
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
this.#options.miner.timestampIncrement === "clock"
providerOptions.miner.timestampIncrement === "clock"

const startTime = this.#options.chain.time || new Date();
const blockTime =
this.#options.miner.timestampIncrement === "clock"
? new ClockBasedBlockTime(startTime, () => new Date())
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
? new ClockBasedBlockTime(startTime, () => new Date())
? new ClockBasedBlockTime(startTime, () => Date.now())

? new ClockBasedBlockTime(startTime, () => new Date())
: new IncrementBasedBlockTime(
startTime,
this.#options.miner.timestampIncrement.toNumber() * 1000
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
this.#options.miner.timestampIncrement.toNumber() * 1000
providerOptions.miner.timestampIncrement.toNumber() * 1000

// whereas in incrementBasedBlockTime we set the time of the _next_ block
// maybe when we call setTime, we increment the value passed in.
const blockTimestampMs = this.block.header.timestamp.toNumber() * 1000;
this.block.header.timestamp = Quantity.from(
Copy link
Member

Choose a reason for hiding this comment

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

chainOptions.time is no longer set and should be. The CLI startup output uses this updated time value when it outputs its startup details.

You can check this by running ganache --fork mainnet --fork.blockNumber 15073145 --miner.timestampIncrement 1

ganache 7.3.2:

Forked Chain
==================
Location:        Ethereum Mainnet, via 丕Infura
Block:           15073145
Network ID:      1
Time:            Sun Jul 03 2022 21:41:07 GMT-0400 (Eastern Daylight Time)

this PR:

Forked Chain
==================
Location:        Ethereum Mainnet, via 丕Infura
Block:           15073145
Network ID:      1
Time:            Tue Jul 19 2022 12:41:59 GMT-0400 (Eastern Daylight Time)

We really should add tests for the CLI output one day 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah. Good to know.

I've set that value, and validated (note the timezone difference!):

Forked Chain
==================
Location:        Ethereum Mainnet, via 丕Infura
Block:           15073145
Network ID:      1
Time:            Mon Jul 04 2022 13:41:06 GMT+1200 (New Zealand Standard Time)

Chain Id
==================
1337

Copy link
Member

Choose a reason for hiding this comment

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

Except the timestamp is now incorrect if ganache is using "clock" mode (since it doesn't use the clock for the first block after the fork block).

Note: I don't particularly like that this is ganache's behavior in "clock" mode.

Copy link
Contributor Author

@jeffsmale90 jeffsmale90 Jul 28, 2022

Choose a reason for hiding this comment

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

I've fixed and added some explicit tests around this behaviour to forking.test.ts

src/chains/ethereum/ethereum/src/api.ts Show resolved Hide resolved
…w Date()). Set chainOptions.time in case where time comes from forked chain.
blockchain = new Blockchain(
options,
fromAddress,
new ClockBasedBlockTime(Date.now, undefined)
Copy link
Member

Choose a reason for hiding this comment

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

No need to pass undefined here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's intentional - #3317 (comment)

I wanted to make it explicit. In real usage in provider.ts we explicitly pass a variable with value undefined. I wanted the tests to explicitly match this, so I didn't make the argument optional.

I'm open to changing that if you feel strongly about it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As per #3317

I added fromSystemClock() static constructor method which means the only time we call this constructor directly is in tests, so I've done as you suggested.

Comment on lines 300 to 315

let numBlocks: number, timestamp: number;
// Since `typeof null === "object"` we have to guard against that
if (arg !== null && typeof arg === "object") {
let { blocks, timestamp } = arg;
if (blocks == null) {
blocks = 1;
}
// TODO(perf): add an option to mine a bunch of blocks in a batch so
// we can save them all to the database in one go.
// Developers like to move the blockchain forward by thousands of blocks
// at a time and doing this would make it way faster
for (let i = 0; i < blocks; i++) {
const { transactions } = await blockchain.mine(
Capacity.FillBlock,
timestamp,
true
);

if (vmErrorsOnRPCResponse) {
assertExceptionalTransactions(transactions);
}
}
numBlocks = arg.blocks == null ? 1 : arg.blocks;
timestamp = arg.timestamp;
} else {
numBlocks = 1;
timestamp = arg as number | null;
}

let timestampMilliseconds: number;
if (timestamp != undefined) {
timestampMilliseconds = timestamp * 1000;
this.#blockchain.setTime(timestampMilliseconds);
}
Copy link
Member

Choose a reason for hiding this comment

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

I don't think we need to setTime here because blockchain.mine already does this if a timestamp is passed in. I think this can be simplified further into something like:

    let numBlocks: number, timestampMilliseconds: number;
    if (arg == null) {
      numBlocks = 1;
    } else if (typeof arg === "object") {
      numBlocks = typeof arg.blocks === "number" ? arg.blocks : 1;
      if (typeof arg.timestamp === "number") {
        timestampMilliseconds = arg.timestamp * 1000;
      }
    } else {
      numBlocks = 1;
      timestampMilliseconds = arg * 1000;
    }

That said, we are converting our time from seconds to milliseconds here, just to convert it back to seconds in readyNextBlock. Maybe this is fine and is the best we can do because the precision of our options in various places is all over the place (some in seconds, some milliseconds).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, you are correct. I've simplified as you suggested.

@@ -107,8 +108,7 @@ describe("provider", () => {
method: "eth_getBlockByNumber",
params: ["latest", false]
});
const expectedTime =
Math.floor((fastForward + +time) / 1000) + timestampIncrement;
const expectedTime = Math.floor((fastForward + +time) / 1000) + timestampIncrement;
Copy link
Member

Choose a reason for hiding this comment

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

Looks like this may need to be prettieried.

@@ -214,6 +216,7 @@ export default class Blockchain extends Emittery<BlockchainTypedEvents> {
constructor(
options: EthereumInternalOptions,
coinbase: Address,
blockTime: BlockTime,
Copy link
Member

Choose a reason for hiding this comment

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

Should we give this a default? Something like blockTime: BlockTime = new ClockBasedBlockTime(Date.now),. I think it could simplify a bunch of tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I prefer to not specify a default in cases such as this, as it encourages more implicit code which is prone to miss-understandings. I also feel that the implementation should be optimised for the "real" use case (while obviously being testable).

I've added a fromSystemClock() static constructor. I intentionally didn't just give getReferenceClockTime argument a default, because I wanted to make it explicit that it's using the system clock.

I think this gives a nice balance, and also tidies up the real world consumption - where we can just call fromSystemClock(startTime) and have the Date.now part abstracted away.

Copy link
Member

Choose a reason for hiding this comment

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

Hm, there is a default, you just put it in a different place. :-)

I'm fine with it... but I can foresee someone creating a test helper that applies actual defaults in the future (like the getProvider() helper does already).

src/chains/ethereum/ethereum/src/block-time.ts Outdated Show resolved Hide resolved
/*
A BlockTime implementation that increments it's reference time by the duration specified by incrementMilliseconds,
every time createBlockTimestampInSeconds() is called. The timestamp returned will be impacted by the timestamp offset,
so can be moved forward and backwards by calling .setOffset() independent of the start time, and will increment when
Copy link
Member

Choose a reason for hiding this comment

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

Can you check this comment for accuracy/consistency? I'm having a hard time following it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, it's a bit mucky, I've made it more concise and easier to follow. 🤞

private readonly _tickReferenceClock: () => void;

constructor(startTime: number, incrementMilliseconds: number) {
let referenceTime = startTime;
Copy link
Member

Choose a reason for hiding this comment

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

I don't think this referenceTime is reset on evm_revert, but I think it should be.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's a really good point. I rethought the approach and went back and forward a couple of times. I've settled on a slight modification, which I think makes sense. The IncrementBasedBlockTime now holds the reference clock at startTime and increments it's offset rather than reference clock.

Offset wasn't working correctly with increment based clocks prior to this change, (as blocktime = previousBlocktime + increment + offset will result in compounding the offset). This change fixes that.

It does mean that the meaning of "offset" wrt increment based clocks is now strictly the elapsed duration (in block time) since the "start time" (plus / minus any modifications made by calls to evm_setTime / evm_increaseTime).

Happy to hear opinions about the above, or we can set up a call to beat out the details.

Copy link
Member

@davidmurdoch davidmurdoch left a comment

Choose a reason for hiding this comment

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

since you told me to hold off on the rest of this review I thought I'd just send off the two comments I have so far

Comment on lines 165 to 177

const startTime =
providerOptions.chain.time === undefined
? Date.now()
: +providerOptions.chain.time;
// if startTime is undefined, BlockTime will determine 0 offset from the reference time
const blockTime =
providerOptions.miner.timestampIncrement === "clock"
? BlockTime.fromSystemClock(startTime)
: new IncrementBasedBlockTime(
startTime,
providerOptions.miner.timestampIncrement.toNumber() * 1000
);
Copy link
Member

Choose a reason for hiding this comment

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

A BlockTime static that abstracts this logic away would really help make the BlockTime logic more cohesive. I'm not a fan of adding all this additional time logic to the provider constructor.

@@ -214,6 +216,7 @@ export default class Blockchain extends Emittery<BlockchainTypedEvents> {
constructor(
options: EthereumInternalOptions,
coinbase: Address,
blockTime: BlockTime,
Copy link
Member

Choose a reason for hiding this comment

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

Hm, there is a default, you just put it in a different place. :-)

I'm fine with it... but I can foresee someone creating a test helper that applies actual defaults in the future (like the getProvider() helper does already).

@jeffsmale90
Copy link
Contributor Author

This PR is now redundant

@jeffsmale90 jeffsmale90 closed this Aug 8, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
Status: Done
3 participants