Skip to content

ProposerVM Epochs POC #3746

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

Draft
wants to merge 27 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f7bca02
proposervm epoch poc
cam-schultz Feb 25, 2025
46f9d06
verify p-chain epoch height
cam-schultz Mar 3, 2025
5e3941a
continuous epoch numbering scheme
cam-schultz May 6, 2025
d6873df
first epoch block sets start time
cam-schultz May 12, 2025
4e8239d
Merge branch 'master' into proposervm-epochs
cam-schultz May 12, 2025
5ec65ff
handle initial epoch
cam-schultz May 12, 2025
a17f763
sealing block's timestamp is next epoch's start time
cam-schultz May 13, 2025
8bcd2b7
Merge branch 'master' into proposervm-epochs
cam-schultz Jun 11, 2025
44dd186
Merge branch 'master' into proposervm-epochs
cam-schultz Jun 30, 2025
f70b717
e2e skeleton
cam-schultz Jun 30, 2025
d94c0ef
expose proposervm API
cam-schultz Jun 23, 2025
d4d5d18
proposervm api client
cam-schultz Jul 3, 2025
f52b784
client fixes
cam-schultz Jul 3, 2025
a19bcba
get epoch method
cam-schultz Jul 3, 2025
8c3babe
observe advancing epoch
cam-schultz Jul 3, 2025
af74732
specify epoch as duration
cam-schultz Jul 7, 2025
dfaad44
epoch e2e test
cam-schultz Jul 7, 2025
dc586f4
specify epoch as duration
cam-schultz Jul 7, 2025
6dd2221
cleanup
cam-schultz Jul 7, 2025
bd2da2c
update mocks
cam-schultz Jul 7, 2025
d1b1e5b
lint
cam-schultz Jul 7, 2025
a560a1d
Merge remote-tracking branch 'origin' into proposervm-epochs
ylg-avalabs Aug 13, 2025
300032c
Merge branch 'master' into proposervm-epochs
geoff-vball Aug 15, 2025
23c634e
Merge branch 'master' into proposervm-epochs-e2e
geoff-vball Aug 18, 2025
7e7f131
update mocks
cam-schultz Jul 7, 2025
36274a7
lint
cam-schultz Jul 7, 2025
71fc71b
Merge branch 'proposervm-epochs' of github.com:ava-labs/avalanche-go …
geoff-vball Aug 18, 2025
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
100 changes: 100 additions & 0 deletions tests/e2e/c/proposervm_epoch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.

package c

import (
"math/big"
"time"

"github.com/ava-labs/libevm/core/types"
"github.com/onsi/ginkgo/v2"
"github.com/stretchr/testify/require"
"go.uber.org/zap"

"github.com/ava-labs/avalanchego/tests/fixture/e2e"
"github.com/ava-labs/avalanchego/utils/units"
"github.com/ava-labs/avalanchego/vms/proposervm"
)

var _ = e2e.DescribeCChain("[ProposerVM Epoch]", func() {
tc := e2e.NewTestContext()
require := require.New(tc)

const txAmount = 10 * units.Avax // Arbitrary amount to send and transfer

ginkgo.It("should advance the proposervm epoch according to the upgrade config epoch duration", func() {
// TODO: Skip this test if Granite is not activated
Copy link
Contributor

Choose a reason for hiding this comment

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

// TODO: Skip this test if Granite is not activated

Why? Can't we force its activation in the test?

Copy link
Contributor

Choose a reason for hiding this comment

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

We can just do upgrades.GraniteTime = upgrade.InitiallyActiveTime and it will make it activated.


env := e2e.GetEnv(tc)
var (
senderKey = env.PreFundedKey
senderEthAddress = senderKey.EthAddress()
recipientKey = e2e.NewPrivateKey(tc)
recipientEthAddress = recipientKey.EthAddress()
)

tc.By("initializing a new eth client")
// Select a random node URI to use for both the eth client and
// the wallet to avoid having to verify that all nodes are at
// the same height before initializing the wallet.
nodeURI := env.GetRandomNodeURI()
ethClient := e2e.NewEthClient(tc, nodeURI)

proposerClient := proposervm.NewClient(nodeURI.URI, "C")

tc.By("issuing C-Chain transactions to advance the epoch", func() {
// Issue enough C-Chain transactions to observe the epoch advancing
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

// Issue enough txs to activate the proposervm form and ensure we advance the epoch (duration is 4s)
const numTxs = 7
Copy link
Contributor

Choose a reason for hiding this comment

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

if the epoch duration is 4 seconds why do we need 7 transactions separated by a second? Can't we do 1 transaction, wait for it to be finalized, sleep 4 seconds, then another transaction?

txCount := 0

initialEpochNumber, _, _, err := proposerClient.GetEpoch(tc.DefaultContext())
require.NoError(err)

for range ticker.C {
acceptedNonce, err := ethClient.AcceptedNonceAt(tc.DefaultContext(), senderEthAddress)
Copy link
Contributor

Choose a reason for hiding this comment

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

These lines aren't a critical part of the test, they're just for sending a transaction to advance the chain. Can we extract the lines 59-79 to a separate function?

require.NoError(err)
gasPrice := e2e.SuggestGasPrice(tc, ethClient)
tx := types.NewTransaction(
acceptedNonce,
recipientEthAddress,
big.NewInt(int64(txAmount)),
e2e.DefaultGasLimit,
gasPrice,
nil,
)

// Sign transaction
cChainID, err := ethClient.ChainID(tc.DefaultContext())
require.NoError(err)
signer := types.NewEIP155Signer(cChainID)
signedTx, err := types.SignTx(tx, signer, senderKey.ToECDSA())
require.NoError(err)

receipt := e2e.SendEthTransaction(tc, ethClient, signedTx)
require.Equal(types.ReceiptStatusSuccessful, receipt.Status)

epochNumber, epochStartTime, pChainHeight, err := proposerClient.GetEpoch(tc.DefaultContext())

Check failure on line 81 in tests/e2e/c/proposervm_epoch.go

View workflow job for this annotation

GitHub Actions / Lint

SA4006: this value of err is never used (staticcheck)

Check failure on line 81 in tests/e2e/c/proposervm_epoch.go

View workflow job for this annotation

GitHub Actions / Lint

ineffectual assignment to err (ineffassign)
tc.Log().Debug(
"epoch",
zap.Uint64("Epoch Number:", epochNumber),
zap.Uint64("Epoch Start Time:", epochStartTime),
zap.Uint64("P-Chain Height:", pChainHeight),
)

txCount++
if txCount >= numTxs {
require.Greater(epochNumber, initialEpochNumber,
"expected epoch number to advance after issuing %d transactions, but it did not",
numTxs,
)
break
}
}
})
})
})
2 changes: 2 additions & 0 deletions tests/e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/base64"
"encoding/json"
"testing"
"time"

"github.com/onsi/ginkgo/v2"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -50,6 +51,7 @@ var _ = ginkgo.SynchronizedBeforeSuite(func() []byte {
upgrades := upgrade.Default
if flagVars.ActivateGranite() {
upgrades.GraniteTime = upgrade.InitiallyActiveTime
upgrades.GraniteEpochDuration = 4 * time.Second
} else {
upgrades.GraniteTime = upgrade.UnscheduledActivationTime
}
Expand Down
34 changes: 18 additions & 16 deletions upgrade/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,28 +77,30 @@ var (
EtnaTime: InitiallyActiveTime,
FortunaTime: InitiallyActiveTime,
GraniteTime: UnscheduledActivationTime,
GraniteEpochDuration: 30 * time.Second,
}

ErrInvalidUpgradeTimes = errors.New("invalid upgrade configuration")
)

type Config struct {
ApricotPhase1Time time.Time `json:"apricotPhase1Time"`
ApricotPhase2Time time.Time `json:"apricotPhase2Time"`
ApricotPhase3Time time.Time `json:"apricotPhase3Time"`
ApricotPhase4Time time.Time `json:"apricotPhase4Time"`
ApricotPhase4MinPChainHeight uint64 `json:"apricotPhase4MinPChainHeight"`
ApricotPhase5Time time.Time `json:"apricotPhase5Time"`
ApricotPhasePre6Time time.Time `json:"apricotPhasePre6Time"`
ApricotPhase6Time time.Time `json:"apricotPhase6Time"`
ApricotPhasePost6Time time.Time `json:"apricotPhasePost6Time"`
BanffTime time.Time `json:"banffTime"`
CortinaTime time.Time `json:"cortinaTime"`
CortinaXChainStopVertexID ids.ID `json:"cortinaXChainStopVertexID"`
DurangoTime time.Time `json:"durangoTime"`
EtnaTime time.Time `json:"etnaTime"`
FortunaTime time.Time `json:"fortunaTime"`
GraniteTime time.Time `json:"graniteTime"`
ApricotPhase1Time time.Time `json:"apricotPhase1Time"`
ApricotPhase2Time time.Time `json:"apricotPhase2Time"`
ApricotPhase3Time time.Time `json:"apricotPhase3Time"`
ApricotPhase4Time time.Time `json:"apricotPhase4Time"`
ApricotPhase4MinPChainHeight uint64 `json:"apricotPhase4MinPChainHeight"`
ApricotPhase5Time time.Time `json:"apricotPhase5Time"`
ApricotPhasePre6Time time.Time `json:"apricotPhasePre6Time"`
ApricotPhase6Time time.Time `json:"apricotPhase6Time"`
ApricotPhasePost6Time time.Time `json:"apricotPhasePost6Time"`
BanffTime time.Time `json:"banffTime"`
CortinaTime time.Time `json:"cortinaTime"`
CortinaXChainStopVertexID ids.ID `json:"cortinaXChainStopVertexID"`
DurangoTime time.Time `json:"durangoTime"`
EtnaTime time.Time `json:"etnaTime"`
FortunaTime time.Time `json:"fortunaTime"`
GraniteTime time.Time `json:"graniteTime"`
GraniteEpochDuration time.Duration `json:"graniteEpochDuration"`
Copy link
Contributor

Choose a reason for hiding this comment

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

shouldn't we add this to Mainnet and Fuji too?

}

func (c *Config) Validate() error {
Expand Down
12 changes: 9 additions & 3 deletions vms/proposervm/batched_vm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,9 @@ func TestBatchedParseBlockParallel(t *testing.T) {
parentID := ids.ID{1}
timestamp := time.Unix(123, 0)
pChainHeight := uint64(2)
pChainEpochHeight := uint64(2)
epochNumber := uint64(0)
epochStartTime := time.Unix(123, 0)
chainID := ids.GenerateTestID()

vm := VM{
Expand All @@ -616,10 +619,10 @@ func TestBatchedParseBlockParallel(t *testing.T) {

blockThatCantBeParsed := snowmantest.BuildChild(snowmantest.Genesis)

blocksWithUnparsable := makeParseableBlocks(t, parentID, timestamp, pChainHeight, cert, chainID, key)
blocksWithUnparsable := makeParseableBlocks(t, parentID, timestamp, pChainHeight, pChainEpochHeight, epochNumber, epochStartTime, cert, chainID, key)
blocksWithUnparsable[50] = blockThatCantBeParsed.Bytes()

parsableBlocks := makeParseableBlocks(t, parentID, timestamp, pChainHeight, cert, chainID, key)
parsableBlocks := makeParseableBlocks(t, parentID, timestamp, pChainHeight, pChainEpochHeight, epochNumber, epochStartTime, cert, chainID, key)

for _, testCase := range []struct {
name string
Expand Down Expand Up @@ -663,14 +666,17 @@ func TestBatchedParseBlockParallel(t *testing.T) {
}
}

func makeParseableBlocks(t *testing.T, parentID ids.ID, timestamp time.Time, pChainHeight uint64, cert *staking.Certificate, chainID ids.ID, key crypto.Signer) [][]byte {
func makeParseableBlocks(t *testing.T, parentID ids.ID, timestamp time.Time, pChainHeight uint64, pChainEpochHeight uint64, epochNumber uint64, epochStartTime time.Time, cert *staking.Certificate, chainID ids.ID, key crypto.Signer) [][]byte {
makeSignedBlock := func(i int) []byte {
buff := binary.AppendVarint(nil, int64(i))

signedBlock, err := blockbuilder.Build(
parentID,
timestamp,
pChainHeight,
pChainEpochHeight,
epochNumber,
epochStartTime,
cert,
buff,
chainID,
Expand Down
108 changes: 103 additions & 5 deletions vms/proposervm/block.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ type Block interface {
buildChild(context.Context) (Block, error)

pChainHeight(context.Context) (uint64, error)
pChainEpochHeight(context.Context) (uint64, error)
Copy link
Contributor

Choose a reason for hiding this comment

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

what is the use of the context here? Isn't the implementation just memory access?

epochNumber(context.Context) (uint64, error)
epochStartTime(context.Context) (time.Time, error)
}

type PostForkBlock interface {
Expand All @@ -79,6 +82,59 @@ func (p *postForkCommonComponents) Height() uint64 {
return p.innerBlk.Height()
}

// Calculates a block's P-Chain epoch height based on its ancestor's epoch membership
func (p *postForkCommonComponents) getPChainEpoch(ctx context.Context, parentID ids.ID) (uint64, uint64, time.Time, error) {
Copy link
Contributor

Choose a reason for hiding this comment

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

can we:

  1. add the parent block as a parameter to this function instead of retrieving it inside the method
  2. move the logger to be a parameter to the method
  3. make this a function and not an instance method, so it can be easily tested
  4. make a unit test that tests this method directly

?

parent, err := p.vm.getBlock(ctx, parentID)
Copy link
Contributor

Choose a reason for hiding this comment

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

what happens if we call this on the genesis block?

if err != nil {
return 0, 0, time.Time{}, err
}
parentTimestamp := parent.Timestamp()
epoch, err := parent.epochNumber(ctx)
if err != nil {
return 0, 0, time.Time{}, fmt.Errorf("failed to get epoch number: %w", err)
}
epochStartTime, err := parent.epochStartTime(ctx)
if err != nil {
return 0, 0, time.Time{}, fmt.Errorf("failed to get epoch start time: %w", err)
}
if epochStartTime.IsZero() {
// If the parent was not assigned an epoch, then the child is the first block of
// the initial epoch.
height, err := parent.pChainHeight(ctx)
if err != nil {
return 0, 0, time.Time{}, fmt.Errorf("failed to get P-Chain height: %w", err)
}
return height, 0, parentTimestamp, nil
}

if parentTimestamp.After(epochStartTime.Add(p.vm.Upgrades.GraniteEpochDuration)) {
// If the parent crossed the epoch boundary, then it sealed the previous epoch. The child
// is the first block of the new epoch, so should use the parent's P-Chain height, increment
// the epoch number, and set the epoch start time to the parent's timestamp.
height, err := parent.pChainHeight(ctx)
if err != nil {
return 0, 0, time.Time{}, fmt.Errorf("failed to get P-Chain height: %w", err)
}
p.vm.ctx.Log.Info("parent sealed epoch. advancing epoch",
zap.Uint64("height", height),
zap.Uint64("epoch", epoch+1),
)
return height, epoch + 1, parentTimestamp, nil
}
// Otherwise, the parent did not seal the previous epoch, so the child should use the parent's
// epoch information. This is true even if the child crosses the epoch boundary, since sealing
// blocks are considered to be part of the epoch they seal.
height, err := parent.pChainEpochHeight(ctx)
if err != nil {
return 0, 0, time.Time{}, fmt.Errorf("failed to get P-Chain height: %w", err)
}
p.vm.ctx.Log.Debug("parent did not seal epoch. using parent's epoch",
zap.Uint64("height", height),
zap.Uint64("epoch", epoch),
)
return height, epoch, epochStartTime, nil
}

// Verify returns nil if:
// 1) [p]'s inner block is not an oracle block
// 2) [child]'s P-Chain height >= [parentPChainHeight]
Expand All @@ -89,6 +145,7 @@ func (p *postForkCommonComponents) Height() uint64 {
// 7) [child]'s timestamp is within its proposer's window
// 8) [child] has a valid signature from its proposer
// 9) [child]'s inner block is valid
// 10) [child] has the expected P-Chain epoch height
Copy link
Contributor

Choose a reason for hiding this comment

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

what about child has the expected epoch number and epoch start time?

We need to verify every field we encode.

func (p *postForkCommonComponents) Verify(
ctx context.Context,
parentTimestamp time.Time,
Expand Down Expand Up @@ -163,9 +220,23 @@ func (p *postForkCommonComponents) Verify(
}

var contextPChainHeight uint64
if p.vm.Upgrades.IsEtnaActivated(childTimestamp) {
switch {
case p.vm.Upgrades.IsGraniteActivated(childTimestamp):
Copy link
Contributor

Choose a reason for hiding this comment

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

what happens if the parent doesn't have granite activated but the child did? How should the system behave?

Copy link
Contributor

Choose a reason for hiding this comment

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

This is the epochStartTime.IsZero() case inside getPChainEpoch right?

pChainEpochHeight, _, _, err := p.getPChainEpoch(ctx, child.Parent())
if err != nil {
Copy link
Contributor

Choose a reason for hiding this comment

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

We're checking the PchainEpochHeight is what it should be, what about the EpochNumber and EpochStartTime? shouldn't we check that too?

p.vm.ctx.Log.Error("unexpected build verification failure",
zap.String("reason", "failed to get P-Chain epoch height"),
zap.Stringer("parentID", child.Parent()),
zap.Error(err),
Copy link
Contributor

Choose a reason for hiding this comment

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

don't we need to fail fast and return an error here?

)
}
if childHeight := child.PChainEpochHeight(); pChainEpochHeight != childHeight {
return fmt.Errorf("epoch height mismatch: expectedEpochHeight %d != epochHeight %d", pChainEpochHeight, childHeight)
}
contextPChainHeight = pChainEpochHeight
case p.vm.Upgrades.IsEtnaActivated(childTimestamp):
contextPChainHeight = childPChainHeight
} else {
default:
contextPChainHeight = parentPChainHeight
}

Expand Down Expand Up @@ -225,10 +296,31 @@ func (p *postForkCommonComponents) buildChild(
return nil, err
}

var contextPChainHeight uint64
if p.vm.Upgrades.IsEtnaActivated(newTimestamp) {
var (
contextPChainHeight, pChainEpochHeight, epochNumber uint64
epochStartTime time.Time
)
switch {
case p.vm.Upgrades.IsGraniteActivated(newTimestamp):
pChainEpochHeight, epochNumber, epochStartTime, err = p.getPChainEpoch(ctx, parentID)
if err != nil {
p.vm.ctx.Log.Error("unexpected build block failure",
zap.String("reason", "failed to get P-Chain epoch height"),
zap.Stringer("parentID", parentID),
zap.Error(err),
)
}
p.vm.ctx.Log.Debug(
"epoch",
zap.Uint64("pChainHeight", pChainHeight),
zap.Uint64("pChainEpochHeight", pChainEpochHeight),
zap.Uint64("epochNumber", epochNumber),
zap.Time("epochStartTime", epochStartTime),
)
contextPChainHeight = pChainEpochHeight
case p.vm.Upgrades.IsEtnaActivated(newTimestamp):
contextPChainHeight = pChainHeight
} else {
default:
contextPChainHeight = parentPChainHeight
}

Expand All @@ -251,6 +343,9 @@ func (p *postForkCommonComponents) buildChild(
parentID,
newTimestamp,
pChainHeight,
pChainEpochHeight,
epochNumber,
epochStartTime,
p.vm.StakingCertLeaf,
innerBlock.Bytes(),
p.vm.ctx.ChainID,
Expand All @@ -261,6 +356,9 @@ func (p *postForkCommonComponents) buildChild(
parentID,
newTimestamp,
pChainHeight,
pChainEpochHeight,
epochNumber,
epochStartTime,
innerBlock.Bytes(),
)
}
Expand Down
Loading
Loading