Skip to content

Proposervm epochs e2e #4055

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 9 commits into
base: proposervm-epochs
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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

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
txCount := 0

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

for range ticker.C {
acceptedNonce, err := ethClient.AcceptedNonceAt(tc.DefaultContext(), senderEthAddress)
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())
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
36 changes: 18 additions & 18 deletions upgrade/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,30 +77,30 @@ var (
EtnaTime: InitiallyActiveTime,
FortunaTime: InitiallyActiveTime,
GraniteTime: UnscheduledActivationTime,
GraniteEpochDuration: 30,
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"`
GraniteEpochDuration uint64 `json:"graniteEpochDuration"`
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"`
}

func (c *Config) Validate() error {
Expand Down
10 changes: 9 additions & 1 deletion vms/proposervm/block.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,18 @@ func (p *postForkCommonComponents) getPChainEpoch(ctx context.Context, parentID
return height, 0, parentTimestamp, nil
}

if parentTimestamp.After(epochStartTime.Add(time.Duration(p.vm.Upgrades.GraniteEpochDuration) * time.Second)) {
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
Expand All @@ -124,6 +128,10 @@ func (p *postForkCommonComponents) getPChainEpoch(ctx context.Context, parentID
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
}

Expand Down
20 changes: 10 additions & 10 deletions vms/proposervm/block/block.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,19 @@ type SignedBlock interface {
}

type statelessUnsignedBlock struct {
ParentID ids.ID `serialize:"true"`
Timestamp int64 `serialize:"true"`
PChainHeight uint64 `serialize:"true"`
PChainEpochHeight uint64 `serialize:"true"`
EpochNumber uint64 `serialize:"true"`
EpochStartTime int64 `serialize:"true"`
Certificate []byte `serialize:"true"`
Block []byte `serialize:"true"`
ParentID ids.ID `serialize:"true" json:"parentID"`
Timestamp int64 `serialize:"true" json:"timestamp"`
PChainHeight uint64 `serialize:"true" json:"pChainHeight"`
PChainEpochHeight uint64 `serialize:"true" json:"pChainEpochHeight"`
EpochNumber uint64 `serialize:"true" json:"epochNumber"`
EpochStartTime int64 `serialize:"true" json:"epochStartTime"`
Certificate []byte `serialize:"true" json:"certificate"`
Block []byte `serialize:"true" json:"block"`
}

type statelessBlock struct {
StatelessBlock statelessUnsignedBlock `serialize:"true"`
Signature []byte `serialize:"true"`
StatelessBlock statelessUnsignedBlock `serialize:"true" json:"block"`
Signature []byte `serialize:"true" json:"signature"`

id ids.ID
timestamp time.Time
Expand Down
62 changes: 62 additions & 0 deletions vms/proposervm/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.

package proposervm

import (
"context"
"fmt"

"github.com/ava-labs/avalanchego/api"
"github.com/ava-labs/avalanchego/ids"
"github.com/ava-labs/avalanchego/utils/formatting"
"github.com/ava-labs/avalanchego/utils/rpc"
)

var _ Client = (*client)(nil)

type Client interface {
// GetProposedHeight returns the current height of this node's proposer VM.
GetProposedHeight(ctx context.Context, options ...rpc.Option) (uint64, error)
// GetProposerBlockWrapper returns the ProposerVM block wrapper
GetProposerBlockWrapper(ctx context.Context, proposerID ids.ID, options ...rpc.Option) ([]byte, error)
// GetEpoch returns the current epoch number, start time, and P-Chain height.
GetEpoch(ctx context.Context, options ...rpc.Option) (uint64, uint64, uint64, error)
}

type client struct {
requester rpc.EndpointRequester
}

// NewClient returns a Client for interacting with the ProposerVM API.
// The provided blockchainName should be the blockchainID or an alias (e.g. "P" for the P-Chain).
func NewClient(uri string, blockchainName string) Client {
return &client{
requester: rpc.NewEndpointRequester(uri + fmt.Sprintf("/ext/bc/%s/proposervm", blockchainName)),
}
}

func (c *client) GetProposedHeight(ctx context.Context, options ...rpc.Option) (uint64, error) {
res := &api.GetHeightResponse{}
err := c.requester.SendRequest(ctx, "proposervm.getProposedHeight", struct{}{}, res, options...)
return uint64(res.Height), err
}

func (c *client) GetProposerBlockWrapper(ctx context.Context, proposerID ids.ID, options ...rpc.Option) ([]byte, error) {
res := &api.FormattedBlock{}
if err := c.requester.SendRequest(ctx, "proposervm.getProposerBlockWrapper", &GetProposerBlockArgs{
ProposerID: proposerID,
Encoding: formatting.Hex,
}, res, options...); err != nil {
return nil, err
}
return formatting.Decode(res.Encoding, res.Block)
}

func (c *client) GetEpoch(ctx context.Context, options ...rpc.Option) (uint64, uint64, uint64, error) {
res := &GetEpochResponse{}
if err := c.requester.SendRequest(ctx, "proposervm.getEpoch", struct{}{}, res, options...); err != nil {
return 0, 0, 0, err
}
return uint64(res.Number), uint64(res.StartTime), uint64(res.PChainHeight), nil
}
106 changes: 106 additions & 0 deletions vms/proposervm/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright (C) 2025, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.

package proposervm

import (
"encoding/json"
"fmt"
"net/http"

"go.uber.org/zap"

"github.com/ava-labs/avalanchego/api"
"github.com/ava-labs/avalanchego/ids"
"github.com/ava-labs/avalanchego/utils/formatting"
avajson "github.com/ava-labs/avalanchego/utils/json"
)

type ProposerAPI struct {
vm *VM
}

func (p *ProposerAPI) GetProposedHeight(_ *http.Request, _ *struct{}, reply *api.GetHeightResponse) error {
p.vm.ctx.Log.Debug("API called",
zap.String("service", "proposervm"),
zap.String("method", "getProposedHeight"),
)

reply.Height = avajson.Uint64(p.vm.lastAcceptedHeight)
return nil
}

// GetProposerBlockArgs is the parameters supplied to the GetProposerBlockWrapper API
type GetProposerBlockArgs struct {
ProposerID ids.ID `json:"proposerID"`
Encoding formatting.Encoding `json:"encoding"`
}

func (p *ProposerAPI) GetProposerBlockWrapper(r *http.Request, args *GetProposerBlockArgs, reply *api.GetBlockResponse) error {
p.vm.ctx.Log.Debug("API called",
zap.String("service", "proposervm"),
zap.String("method", "getProposerBlockWrapper"),
zap.String("proposerID", args.ProposerID.String()),
zap.String("encoding", args.Encoding.String()),
)

block, err := p.vm.GetBlock(r.Context(), args.ProposerID)
if err != nil {
return err
}
reply.Encoding = args.Encoding

var result any
if args.Encoding == formatting.JSON {
result = block
} else {
result, err = formatting.Encode(args.Encoding, block.Bytes())
if err != nil {
return fmt.Errorf("couldn't encode block %s as %s: %w", args.ProposerID, args.Encoding, err)
}
}

reply.Block, err = json.Marshal(result)
return nil
}

type GetEpochResponse struct {
Number avajson.Uint64 `json:"Number"`
StartTime avajson.Uint64 `json:"StartTime"`
PChainHeight avajson.Uint64 `json:"pChainHeight"`
}

func (p *ProposerAPI) GetEpoch(r *http.Request, _ *struct{}, reply *GetEpochResponse) error {
p.vm.ctx.Log.Debug("API called",
zap.String("service", "proposervm"),
zap.String("method", "getEpoch"),
)

lastAccepted, err := p.vm.LastAccepted(r.Context())
if err != nil {
return fmt.Errorf("couldn't get last accepted block ID: %w", err)
}
latestBlock, err := p.vm.getBlock(r.Context(), lastAccepted)
if err != nil {
return fmt.Errorf("couldn't get latest block: %w", err)
}

epochNumber, err := latestBlock.epochNumber(r.Context())
if err != nil {
return fmt.Errorf("couldn't get epoch number: %w", err)
}
epochStartTime, err := latestBlock.epochStartTime(r.Context())
if err != nil {
return fmt.Errorf("couldn't get epoch start time: %w", err)
}
epochPChainHeight, err := latestBlock.pChainEpochHeight(r.Context())
if err != nil {
return fmt.Errorf("couldn't get epoch P-Chain height: %w", err)
}

reply.Number = avajson.Uint64(epochNumber)
reply.StartTime = avajson.Uint64(epochStartTime.Unix())
reply.PChainHeight = avajson.Uint64(epochPChainHeight)

return nil
}
Loading
Loading