Skip to content

Commit

Permalink
Merge pull request #36 from keep-network/the-bid
Browse files Browse the repository at this point in the history
See keep-network/keep-core#1803

For every submitted transaction, we check in the predefined intervals if the transaction has been mined already and if not, we increase the gas price by 20% and try to submit again.

The interval at which we check for transaction status, as well as the maximum gas price, can be configured.
  • Loading branch information
nkuba authored May 15, 2020
2 parents 8fbb5b3 + 9036c7c commit 9fbd0b9
Show file tree
Hide file tree
Showing 10 changed files with 506 additions and 4 deletions.
12 changes: 12 additions & 0 deletions pkg/chain/ethereum/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ type Config struct {
ContractAddresses map[string]string

Account Account

// MiningCheckInterval is the interval in which transaction
// mining status is checked. If the transaction is not mined within this
// time, the gas price is increased and transaction is resubmitted.
MiningCheckInterval int

// MaxGasPrice specifies the maximum gas price the client is
// willing to pay for the transaction to be mined. The offered transaction
// gas price can not be higher than the max gas price value. If the maximum
// allowed gas price is reached, no further resubmission attempts are
// performed.
MaxGasPrice uint64
}

// ContractAddress finds a given contract's address configuration and returns it
Expand Down
149 changes: 149 additions & 0 deletions pkg/chain/ethereum/ethutil/mine_waiter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package ethutil

import (
"context"
"math/big"
"time"

"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/core/types"
)

// MiningWaiter allows to block the execution until the given transaction is
// mined as well as monitor the transaction and bump up the gas price in case
// it is not mined in the given timeout.
type MiningWaiter struct {
backend bind.DeployBackend
checkInterval time.Duration
maxGasPrice *big.Int
}

// NewMiningWaiter creates a new MiningWaiter instance for the provided
// client backend. It accepts two parameters setting up monitoring rules of the
// transaction mining status.
//
// Check interval is the time given for the transaction to be mined. If the
// transaction is not mined within that time, the gas price is increased by
// 20% and transaction is replaced with the one with a higher gas price.
//
// Max gas price specifies the maximum gas price the client is willing to pay
// for the transaction to be mined. The offered transaction gas price can not
// be higher than this value. If the maximum allowed gas price is reached, no
// further resubmission attempts are performed.
func NewMiningWaiter(
backend bind.DeployBackend,
checkInterval time.Duration,
maxGasPrice *big.Int,
) *MiningWaiter {
return &MiningWaiter{
backend,
checkInterval,
maxGasPrice,
}
}

// WaitMined blocks the current execution until the transaction with the given
// hash is mined. Execution is blocked until the transaction is mined or until
// the given timeout passes.
func (mw *MiningWaiter) WaitMined(
timeout time.Duration,
tx *types.Transaction,
) (*types.Receipt, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()

queryTicker := time.NewTicker(time.Second)
defer queryTicker.Stop()

for {
receipt, _ := mw.backend.TransactionReceipt(context.TODO(), tx.Hash())
if receipt != nil {
return receipt, nil
}

select {
case <-ctx.Done():
return nil, ctx.Err()
case <-queryTicker.C:
}
}
}

// ResubmitTransactionFn implements the code for resubmitting the transaction
// with the higher gas price. It should guarantee the same nonce is used for
// transaction resubmission.
type ResubmitTransactionFn func(gasPrice *big.Int) (*types.Transaction, error)

// ForceMining blocks until the transaction is mined and bumps up the gas price
// by 20% in the intervals defined by MiningWaiter in case the transaction has
// not been mined yet. It accepts the original transaction reference and the
// function responsible for executing transaction resubmission.
func (mw MiningWaiter) ForceMining(
originalTransaction *types.Transaction,
resubmitFn ResubmitTransactionFn,
) {
// if the original transaction's gas price was higher or equal the max
// allowed we do nothing; we need to wait for it to be mined
if originalTransaction.GasPrice().Cmp(mw.maxGasPrice) >= 0 {
logger.Infof(
"original transaction gas price is higher than the max allowed; " +
"skipping resubmissions",
)
return
}

transaction := originalTransaction
for {
receipt, err := mw.WaitMined(mw.checkInterval, transaction)
if err != nil {
logger.Infof(
"transaction [%v] not yet mined: [%v]",
transaction.Hash().TerminalString(),
err,
)
}

// transaction mined, we are good
if receipt != nil {
logger.Infof(
"transaction [%v] mined with status [%v] at block [%v]",
transaction.Hash().TerminalString(),
receipt.Status,
receipt.BlockNumber,
)
return
}

// transaction not yet mined, if the previous gas price was the maximum
// one, we no longer resubmit
gasPrice := transaction.GasPrice()
if gasPrice.Cmp(mw.maxGasPrice) == 0 {
logger.Infof("reached the maximum allowed gas price; stopping resubmissions")
return
}

// if we still have some margin, add 20% to the previous gas price
twentyPercent := new(big.Int).Div(gasPrice, big.NewInt(5))
gasPrice = new(big.Int).Add(gasPrice, twentyPercent)

// if we reached the maximum allowed gas price, submit one more time
// with the maximum
if gasPrice.Cmp(mw.maxGasPrice) > 0 {
gasPrice = mw.maxGasPrice
}

// transaction not yet mined and we are still under the maximum allowed
// gas price; resubmitting transaction with 20% higher gas price
// evaluated earlier
logger.Infof(
"resubmitting previous transaction [%v] with a higher gas price [%v]",
transaction.Hash().TerminalString(),
gasPrice,
)
transaction, err = resubmitFn(gasPrice)
if err != nil {
logger.Warningf("could not resubmit TX with a higher gas price: [%v]", err)
return
}
}
}
226 changes: 226 additions & 0 deletions pkg/chain/ethereum/ethutil/mine_waiter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
package ethutil

import (
"context"
"math/big"
"testing"
"time"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
)

const checkInterval = 100 * time.Millisecond

var maxGasPrice = big.NewInt(45000000000) // 45 Gwei

func TestForceMining_FirstMined(t *testing.T) {
originalTransaction := createTransaction(big.NewInt(20000000000)) // 20 Gwei

mockBackend := &mockDeployBackend{}

var resubmissionGasPrices []*big.Int

resubmitFn := func(gasPrice *big.Int) (*types.Transaction, error) {
resubmissionGasPrices = append(resubmissionGasPrices, gasPrice)
return createTransaction(gasPrice), nil
}

// receipt is already there
mockBackend.receipt = &types.Receipt{}

waiter := NewMiningWaiter(mockBackend, checkInterval, maxGasPrice)
waiter.ForceMining(
originalTransaction,
resubmitFn,
)

resubmissionCount := len(resubmissionGasPrices)
if resubmissionCount != 0 {
t.Fatalf("expected no resubmissions; has: [%v]", resubmissionCount)
}
}

func TestForceMining_SecondMined(t *testing.T) {
originalTransaction := createTransaction(big.NewInt(20000000000)) // 20 Gwei

mockBackend := &mockDeployBackend{}

var resubmissionGasPrices []*big.Int

resubmitFn := func(gasPrice *big.Int) (*types.Transaction, error) {
resubmissionGasPrices = append(resubmissionGasPrices, gasPrice)
// first resubmission succeeded
mockBackend.receipt = &types.Receipt{}
return createTransaction(gasPrice), nil
}

waiter := NewMiningWaiter(mockBackend, checkInterval, maxGasPrice)
waiter.ForceMining(
originalTransaction,
resubmitFn,
)

resubmissionCount := len(resubmissionGasPrices)
if resubmissionCount != 1 {
t.Fatalf("expected one resubmission; has: [%v]", resubmissionCount)
}
}

func TestForceMining_MultipleAttempts(t *testing.T) {
originalTransaction := createTransaction(big.NewInt(20000000000)) // 20 Gwei

mockBackend := &mockDeployBackend{}

var resubmissionGasPrices []*big.Int

expectedAttempts := 3
expectedResubmissionGasPrices := []*big.Int{
big.NewInt(24000000000), // + 20%
big.NewInt(28800000000), // + 20%
big.NewInt(34560000000), // + 20%
}

attemptsSoFar := 1
resubmitFn := func(gasPrice *big.Int) (*types.Transaction, error) {
resubmissionGasPrices = append(resubmissionGasPrices, gasPrice)
if attemptsSoFar == expectedAttempts {
mockBackend.receipt = &types.Receipt{}
} else {
attemptsSoFar++
}
return createTransaction(gasPrice), nil
}

waiter := NewMiningWaiter(mockBackend, checkInterval, maxGasPrice)
waiter.ForceMining(
originalTransaction,
resubmitFn,
)

resubmissionCount := len(resubmissionGasPrices)
if resubmissionCount != expectedAttempts {
t.Fatalf(
"expected [%v] resubmission; has: [%v]",
expectedAttempts,
resubmissionCount,
)
}

for resubmission, price := range resubmissionGasPrices {
if price.Cmp(expectedResubmissionGasPrices[resubmission]) != 0 {
t.Fatalf(
"unexpected [%v] resubmission gas price\nexpected: [%v]\nactual: [%v]",
resubmission,
expectedResubmissionGasPrices[resubmission],
price,
)
}
}
}

func TestForceMining_MaxAllowedPriceReached(t *testing.T) {
originalTransaction := createTransaction(big.NewInt(20000000000)) // 20 Gwei

mockBackend := &mockDeployBackend{}

var resubmissionGasPrices []*big.Int

expectedAttempts := 5
expectedResubmissionGasPrices := []*big.Int{
big.NewInt(24000000000), // + 20%
big.NewInt(28800000000), // + 20%
big.NewInt(34560000000), // + 20%
big.NewInt(41472000000), // + 20%
big.NewInt(45000000000), // max allowed
}

resubmitFn := func(gasPrice *big.Int) (*types.Transaction, error) {
resubmissionGasPrices = append(resubmissionGasPrices, gasPrice)
// not setting mockBackend.receipt, mining takes a very long time
return createTransaction(gasPrice), nil
}

waiter := NewMiningWaiter(mockBackend, checkInterval, maxGasPrice)
waiter.ForceMining(
originalTransaction,
resubmitFn,
)

resubmissionCount := len(resubmissionGasPrices)
if resubmissionCount != expectedAttempts {
t.Fatalf(
"expected [%v] resubmission; has: [%v]",
expectedAttempts,
resubmissionCount,
)
}

for resubmission, price := range resubmissionGasPrices {
if price.Cmp(expectedResubmissionGasPrices[resubmission]) != 0 {
t.Fatalf(
"unexpected [%v] resubmission gas price\nexpected: [%v]\nactual: [%v]",
resubmission,
expectedResubmissionGasPrices[resubmission],
price,
)
}
}
}

func TestForceMining_OriginalPriceHigherThanMaxAllowed(t *testing.T) {
// original transaction was priced at 46 Gwei, the maximum allowed gas price
// is 45 Gwei
originalTransaction := createTransaction(big.NewInt(46000000000))

mockBackend := &mockDeployBackend{}

var resubmissionGasPrices []*big.Int

resubmitFn := func(gasPrice *big.Int) (*types.Transaction, error) {
resubmissionGasPrices = append(resubmissionGasPrices, gasPrice)
// not setting mockBackend.receipt, mining takes a very long time
return createTransaction(gasPrice), nil
}

waiter := NewMiningWaiter(mockBackend, checkInterval, maxGasPrice)
waiter.ForceMining(
originalTransaction,
resubmitFn,
)

resubmissionCount := len(resubmissionGasPrices)
if resubmissionCount != 0 {
t.Fatalf("expected no resubmissions; has: [%v]", resubmissionCount)
}
}

func createTransaction(gasPrice *big.Int) *types.Transaction {
return types.NewTransaction(
10, // nonce
common.HexToAddress("0x131D387731bBbC988B312206c74F77D004D6B84b"), // to
big.NewInt(0), // amount
200000, // gas limit
gasPrice, // gas price
[]byte{}, // data
)
}

type mockDeployBackend struct {
receipt *types.Receipt
}

func (mdb *mockDeployBackend) TransactionReceipt(
ctx context.Context,
txHash common.Hash,
) (*types.Receipt, error) {
return mdb.receipt, nil
}

func (mdb *mockDeployBackend) CodeAt(
ctx context.Context,
account common.Address,
blockNumber *big.Int,
) ([]byte, error) {
panic("not implemented")
}
Loading

0 comments on commit 9fbd0b9

Please sign in to comment.