Skip to content

Commit 9fbd0b9

Browse files
authored
Merge pull request #36 from keep-network/the-bid
See threshold-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.
2 parents 8fbb5b3 + 9036c7c commit 9fbd0b9

File tree

10 files changed

+506
-4
lines changed

10 files changed

+506
-4
lines changed

pkg/chain/ethereum/config.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ type Config struct {
3838
ContractAddresses map[string]string
3939

4040
Account Account
41+
42+
// MiningCheckInterval is the interval in which transaction
43+
// mining status is checked. If the transaction is not mined within this
44+
// time, the gas price is increased and transaction is resubmitted.
45+
MiningCheckInterval int
46+
47+
// MaxGasPrice specifies the maximum gas price the client is
48+
// willing to pay for the transaction to be mined. The offered transaction
49+
// gas price can not be higher than the max gas price value. If the maximum
50+
// allowed gas price is reached, no further resubmission attempts are
51+
// performed.
52+
MaxGasPrice uint64
4153
}
4254

4355
// ContractAddress finds a given contract's address configuration and returns it
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package ethutil
2+
3+
import (
4+
"context"
5+
"math/big"
6+
"time"
7+
8+
"github.com/ethereum/go-ethereum/accounts/abi/bind"
9+
"github.com/ethereum/go-ethereum/core/types"
10+
)
11+
12+
// MiningWaiter allows to block the execution until the given transaction is
13+
// mined as well as monitor the transaction and bump up the gas price in case
14+
// it is not mined in the given timeout.
15+
type MiningWaiter struct {
16+
backend bind.DeployBackend
17+
checkInterval time.Duration
18+
maxGasPrice *big.Int
19+
}
20+
21+
// NewMiningWaiter creates a new MiningWaiter instance for the provided
22+
// client backend. It accepts two parameters setting up monitoring rules of the
23+
// transaction mining status.
24+
//
25+
// Check interval is the time given for the transaction to be mined. If the
26+
// transaction is not mined within that time, the gas price is increased by
27+
// 20% and transaction is replaced with the one with a higher gas price.
28+
//
29+
// Max gas price specifies the maximum gas price the client is willing to pay
30+
// for the transaction to be mined. The offered transaction gas price can not
31+
// be higher than this value. If the maximum allowed gas price is reached, no
32+
// further resubmission attempts are performed.
33+
func NewMiningWaiter(
34+
backend bind.DeployBackend,
35+
checkInterval time.Duration,
36+
maxGasPrice *big.Int,
37+
) *MiningWaiter {
38+
return &MiningWaiter{
39+
backend,
40+
checkInterval,
41+
maxGasPrice,
42+
}
43+
}
44+
45+
// WaitMined blocks the current execution until the transaction with the given
46+
// hash is mined. Execution is blocked until the transaction is mined or until
47+
// the given timeout passes.
48+
func (mw *MiningWaiter) WaitMined(
49+
timeout time.Duration,
50+
tx *types.Transaction,
51+
) (*types.Receipt, error) {
52+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
53+
defer cancel()
54+
55+
queryTicker := time.NewTicker(time.Second)
56+
defer queryTicker.Stop()
57+
58+
for {
59+
receipt, _ := mw.backend.TransactionReceipt(context.TODO(), tx.Hash())
60+
if receipt != nil {
61+
return receipt, nil
62+
}
63+
64+
select {
65+
case <-ctx.Done():
66+
return nil, ctx.Err()
67+
case <-queryTicker.C:
68+
}
69+
}
70+
}
71+
72+
// ResubmitTransactionFn implements the code for resubmitting the transaction
73+
// with the higher gas price. It should guarantee the same nonce is used for
74+
// transaction resubmission.
75+
type ResubmitTransactionFn func(gasPrice *big.Int) (*types.Transaction, error)
76+
77+
// ForceMining blocks until the transaction is mined and bumps up the gas price
78+
// by 20% in the intervals defined by MiningWaiter in case the transaction has
79+
// not been mined yet. It accepts the original transaction reference and the
80+
// function responsible for executing transaction resubmission.
81+
func (mw MiningWaiter) ForceMining(
82+
originalTransaction *types.Transaction,
83+
resubmitFn ResubmitTransactionFn,
84+
) {
85+
// if the original transaction's gas price was higher or equal the max
86+
// allowed we do nothing; we need to wait for it to be mined
87+
if originalTransaction.GasPrice().Cmp(mw.maxGasPrice) >= 0 {
88+
logger.Infof(
89+
"original transaction gas price is higher than the max allowed; " +
90+
"skipping resubmissions",
91+
)
92+
return
93+
}
94+
95+
transaction := originalTransaction
96+
for {
97+
receipt, err := mw.WaitMined(mw.checkInterval, transaction)
98+
if err != nil {
99+
logger.Infof(
100+
"transaction [%v] not yet mined: [%v]",
101+
transaction.Hash().TerminalString(),
102+
err,
103+
)
104+
}
105+
106+
// transaction mined, we are good
107+
if receipt != nil {
108+
logger.Infof(
109+
"transaction [%v] mined with status [%v] at block [%v]",
110+
transaction.Hash().TerminalString(),
111+
receipt.Status,
112+
receipt.BlockNumber,
113+
)
114+
return
115+
}
116+
117+
// transaction not yet mined, if the previous gas price was the maximum
118+
// one, we no longer resubmit
119+
gasPrice := transaction.GasPrice()
120+
if gasPrice.Cmp(mw.maxGasPrice) == 0 {
121+
logger.Infof("reached the maximum allowed gas price; stopping resubmissions")
122+
return
123+
}
124+
125+
// if we still have some margin, add 20% to the previous gas price
126+
twentyPercent := new(big.Int).Div(gasPrice, big.NewInt(5))
127+
gasPrice = new(big.Int).Add(gasPrice, twentyPercent)
128+
129+
// if we reached the maximum allowed gas price, submit one more time
130+
// with the maximum
131+
if gasPrice.Cmp(mw.maxGasPrice) > 0 {
132+
gasPrice = mw.maxGasPrice
133+
}
134+
135+
// transaction not yet mined and we are still under the maximum allowed
136+
// gas price; resubmitting transaction with 20% higher gas price
137+
// evaluated earlier
138+
logger.Infof(
139+
"resubmitting previous transaction [%v] with a higher gas price [%v]",
140+
transaction.Hash().TerminalString(),
141+
gasPrice,
142+
)
143+
transaction, err = resubmitFn(gasPrice)
144+
if err != nil {
145+
logger.Warningf("could not resubmit TX with a higher gas price: [%v]", err)
146+
return
147+
}
148+
}
149+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
package ethutil
2+
3+
import (
4+
"context"
5+
"math/big"
6+
"testing"
7+
"time"
8+
9+
"github.com/ethereum/go-ethereum/common"
10+
"github.com/ethereum/go-ethereum/core/types"
11+
)
12+
13+
const checkInterval = 100 * time.Millisecond
14+
15+
var maxGasPrice = big.NewInt(45000000000) // 45 Gwei
16+
17+
func TestForceMining_FirstMined(t *testing.T) {
18+
originalTransaction := createTransaction(big.NewInt(20000000000)) // 20 Gwei
19+
20+
mockBackend := &mockDeployBackend{}
21+
22+
var resubmissionGasPrices []*big.Int
23+
24+
resubmitFn := func(gasPrice *big.Int) (*types.Transaction, error) {
25+
resubmissionGasPrices = append(resubmissionGasPrices, gasPrice)
26+
return createTransaction(gasPrice), nil
27+
}
28+
29+
// receipt is already there
30+
mockBackend.receipt = &types.Receipt{}
31+
32+
waiter := NewMiningWaiter(mockBackend, checkInterval, maxGasPrice)
33+
waiter.ForceMining(
34+
originalTransaction,
35+
resubmitFn,
36+
)
37+
38+
resubmissionCount := len(resubmissionGasPrices)
39+
if resubmissionCount != 0 {
40+
t.Fatalf("expected no resubmissions; has: [%v]", resubmissionCount)
41+
}
42+
}
43+
44+
func TestForceMining_SecondMined(t *testing.T) {
45+
originalTransaction := createTransaction(big.NewInt(20000000000)) // 20 Gwei
46+
47+
mockBackend := &mockDeployBackend{}
48+
49+
var resubmissionGasPrices []*big.Int
50+
51+
resubmitFn := func(gasPrice *big.Int) (*types.Transaction, error) {
52+
resubmissionGasPrices = append(resubmissionGasPrices, gasPrice)
53+
// first resubmission succeeded
54+
mockBackend.receipt = &types.Receipt{}
55+
return createTransaction(gasPrice), nil
56+
}
57+
58+
waiter := NewMiningWaiter(mockBackend, checkInterval, maxGasPrice)
59+
waiter.ForceMining(
60+
originalTransaction,
61+
resubmitFn,
62+
)
63+
64+
resubmissionCount := len(resubmissionGasPrices)
65+
if resubmissionCount != 1 {
66+
t.Fatalf("expected one resubmission; has: [%v]", resubmissionCount)
67+
}
68+
}
69+
70+
func TestForceMining_MultipleAttempts(t *testing.T) {
71+
originalTransaction := createTransaction(big.NewInt(20000000000)) // 20 Gwei
72+
73+
mockBackend := &mockDeployBackend{}
74+
75+
var resubmissionGasPrices []*big.Int
76+
77+
expectedAttempts := 3
78+
expectedResubmissionGasPrices := []*big.Int{
79+
big.NewInt(24000000000), // + 20%
80+
big.NewInt(28800000000), // + 20%
81+
big.NewInt(34560000000), // + 20%
82+
}
83+
84+
attemptsSoFar := 1
85+
resubmitFn := func(gasPrice *big.Int) (*types.Transaction, error) {
86+
resubmissionGasPrices = append(resubmissionGasPrices, gasPrice)
87+
if attemptsSoFar == expectedAttempts {
88+
mockBackend.receipt = &types.Receipt{}
89+
} else {
90+
attemptsSoFar++
91+
}
92+
return createTransaction(gasPrice), nil
93+
}
94+
95+
waiter := NewMiningWaiter(mockBackend, checkInterval, maxGasPrice)
96+
waiter.ForceMining(
97+
originalTransaction,
98+
resubmitFn,
99+
)
100+
101+
resubmissionCount := len(resubmissionGasPrices)
102+
if resubmissionCount != expectedAttempts {
103+
t.Fatalf(
104+
"expected [%v] resubmission; has: [%v]",
105+
expectedAttempts,
106+
resubmissionCount,
107+
)
108+
}
109+
110+
for resubmission, price := range resubmissionGasPrices {
111+
if price.Cmp(expectedResubmissionGasPrices[resubmission]) != 0 {
112+
t.Fatalf(
113+
"unexpected [%v] resubmission gas price\nexpected: [%v]\nactual: [%v]",
114+
resubmission,
115+
expectedResubmissionGasPrices[resubmission],
116+
price,
117+
)
118+
}
119+
}
120+
}
121+
122+
func TestForceMining_MaxAllowedPriceReached(t *testing.T) {
123+
originalTransaction := createTransaction(big.NewInt(20000000000)) // 20 Gwei
124+
125+
mockBackend := &mockDeployBackend{}
126+
127+
var resubmissionGasPrices []*big.Int
128+
129+
expectedAttempts := 5
130+
expectedResubmissionGasPrices := []*big.Int{
131+
big.NewInt(24000000000), // + 20%
132+
big.NewInt(28800000000), // + 20%
133+
big.NewInt(34560000000), // + 20%
134+
big.NewInt(41472000000), // + 20%
135+
big.NewInt(45000000000), // max allowed
136+
}
137+
138+
resubmitFn := func(gasPrice *big.Int) (*types.Transaction, error) {
139+
resubmissionGasPrices = append(resubmissionGasPrices, gasPrice)
140+
// not setting mockBackend.receipt, mining takes a very long time
141+
return createTransaction(gasPrice), nil
142+
}
143+
144+
waiter := NewMiningWaiter(mockBackend, checkInterval, maxGasPrice)
145+
waiter.ForceMining(
146+
originalTransaction,
147+
resubmitFn,
148+
)
149+
150+
resubmissionCount := len(resubmissionGasPrices)
151+
if resubmissionCount != expectedAttempts {
152+
t.Fatalf(
153+
"expected [%v] resubmission; has: [%v]",
154+
expectedAttempts,
155+
resubmissionCount,
156+
)
157+
}
158+
159+
for resubmission, price := range resubmissionGasPrices {
160+
if price.Cmp(expectedResubmissionGasPrices[resubmission]) != 0 {
161+
t.Fatalf(
162+
"unexpected [%v] resubmission gas price\nexpected: [%v]\nactual: [%v]",
163+
resubmission,
164+
expectedResubmissionGasPrices[resubmission],
165+
price,
166+
)
167+
}
168+
}
169+
}
170+
171+
func TestForceMining_OriginalPriceHigherThanMaxAllowed(t *testing.T) {
172+
// original transaction was priced at 46 Gwei, the maximum allowed gas price
173+
// is 45 Gwei
174+
originalTransaction := createTransaction(big.NewInt(46000000000))
175+
176+
mockBackend := &mockDeployBackend{}
177+
178+
var resubmissionGasPrices []*big.Int
179+
180+
resubmitFn := func(gasPrice *big.Int) (*types.Transaction, error) {
181+
resubmissionGasPrices = append(resubmissionGasPrices, gasPrice)
182+
// not setting mockBackend.receipt, mining takes a very long time
183+
return createTransaction(gasPrice), nil
184+
}
185+
186+
waiter := NewMiningWaiter(mockBackend, checkInterval, maxGasPrice)
187+
waiter.ForceMining(
188+
originalTransaction,
189+
resubmitFn,
190+
)
191+
192+
resubmissionCount := len(resubmissionGasPrices)
193+
if resubmissionCount != 0 {
194+
t.Fatalf("expected no resubmissions; has: [%v]", resubmissionCount)
195+
}
196+
}
197+
198+
func createTransaction(gasPrice *big.Int) *types.Transaction {
199+
return types.NewTransaction(
200+
10, // nonce
201+
common.HexToAddress("0x131D387731bBbC988B312206c74F77D004D6B84b"), // to
202+
big.NewInt(0), // amount
203+
200000, // gas limit
204+
gasPrice, // gas price
205+
[]byte{}, // data
206+
)
207+
}
208+
209+
type mockDeployBackend struct {
210+
receipt *types.Receipt
211+
}
212+
213+
func (mdb *mockDeployBackend) TransactionReceipt(
214+
ctx context.Context,
215+
txHash common.Hash,
216+
) (*types.Receipt, error) {
217+
return mdb.receipt, nil
218+
}
219+
220+
func (mdb *mockDeployBackend) CodeAt(
221+
ctx context.Context,
222+
account common.Address,
223+
blockNumber *big.Int,
224+
) ([]byte, error) {
225+
panic("not implemented")
226+
}

0 commit comments

Comments
 (0)