Skip to content

Commit 846a112

Browse files
committed
finalizer
1 parent 0840826 commit 846a112

7 files changed

Lines changed: 1679 additions & 3 deletions

File tree

finalizer/README.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Finalizer
2+
3+
A wallet adapter for guaranteeing transaction inclusion on a specific chain.
4+
5+
This fixes "nonce too low" issues that can happen if reorgs occur or if you trust your node's reported nonces.
6+
7+
## Usage
8+
9+
### Create a mempool
10+
11+
For demonstration:
12+
13+
```go
14+
mempool := finalizer.NewMemoryMempool[struct{}]()
15+
```
16+
17+
Here `struct{}` can be any type for transaction metadata, data that gets persisted with the transaction, but not sent on chain.
18+
19+
For production, implement the `Mempool` interface and persist to a database instead.
20+
21+
### Create a chain provider using ethkit
22+
23+
For EIP-1559 chains:
24+
25+
```go
26+
chain, err := finalizer.NewEthkitChain(finalizer.EthkitChainOptions{
27+
ChainID: big.NewInt(1),
28+
IsEIP1559: true,
29+
Provider: provider,
30+
Monitor: monitor, // must be running
31+
GasGauge: nil, // not used for EIP-1559 chains
32+
PriorityFee: big.NewInt(1000000000), // required for EIP-1559 chains
33+
})
34+
```
35+
36+
For non-EIP-1559 chains:
37+
38+
```go
39+
chain, err := finalizer.NewEthkitChain(finalizer.EthkitChainOptions{
40+
ChainID: big.NewInt(56),
41+
IsEIP1559: false,
42+
Provider: provider,
43+
Monitor: monitor, // must be running
44+
GasGauge: gasGauge, // required for non-EIP-1559 chains
45+
GasGaugeSpeed: finalizer.GasGaugeSpeedDefault // default = fast
46+
PriorityFee: nil, // not used for non-EIP-1559 chains
47+
})
48+
```
49+
50+
### Create a finalizer for a specific wallet on a specific chain
51+
52+
```go
53+
f, err := finalizer.NewFinalizer(finalizer.FinalizerOptions[struct{}]{
54+
Wallet: wallet,
55+
Chain: chain,
56+
Mempool: mempool,
57+
Logger: nil,
58+
PollInterval: 5 * time.Second, // period between chain state checks
59+
PollTimeout: 4 * time.Second, // time limit for operations while checking chain state
60+
RetryDelay: 24 * time.Second, // minimum time to wait before retrying a transaction
61+
FeeMargin: 25, // percentage added on top of the estimated gas price
62+
PriceBump: 15, // go-ethereum requires at least 10% by default
63+
})
64+
```
65+
66+
The finalizer has a blocking run loop that must be called for it to work:
67+
68+
```go
69+
err := f.Run(ctx)
70+
```
71+
72+
### Subscribe to mining and reorg events
73+
74+
```go
75+
for event := range f.Subscribe(ctx) {
76+
if event.Added != nil {
77+
if event.Removed == nil {
78+
fmt.Println(
79+
"mined",
80+
event.Added.Hash(),
81+
event.Added.Metadata,
82+
)
83+
} else {
84+
fmt.Println(
85+
"reorged",
86+
event.Removed.Hash(),
87+
event.Removed.Metadata,
88+
"->",
89+
event.Added.Hash(),
90+
event.Added.Metadata,
91+
)
92+
}
93+
} else if event.Removed != nil {
94+
fmt.Println(
95+
"reorged",
96+
event.Removed.Hash(),
97+
event.Removed.Metadata,
98+
)
99+
}
100+
}
101+
```
102+
103+
### Send a transaction
104+
105+
```go
106+
signed, err := f.Send(ctx, unsigned, struct{}{})
107+
```
108+
109+
The `struct{}{}` argument here is the transaction's metadata.
110+
The returned signed transaction may or may not be the final transaction included on chain.

finalizer/chain.go

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
package finalizer
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"math/big"
8+
"sync"
9+
10+
"github.com/0xsequence/ethkit/ethgas"
11+
"github.com/0xsequence/ethkit/ethmonitor"
12+
"github.com/0xsequence/ethkit/ethrpc"
13+
"github.com/0xsequence/ethkit/go-ethereum/common"
14+
"github.com/0xsequence/ethkit/go-ethereum/core/types"
15+
)
16+
17+
// Chain is a provider for a chain where transactions will be sent.
18+
type Chain interface {
19+
ChainID() *big.Int
20+
IsEIP1559() bool
21+
22+
LatestNonce(ctx context.Context, address common.Address) (uint64, error)
23+
PendingNonce(ctx context.Context, address common.Address) (uint64, error)
24+
25+
Send(ctx context.Context, transaction *types.Transaction) error
26+
27+
GasPrice(ctx context.Context) (*big.Int, error)
28+
BaseFee(ctx context.Context) (*big.Int, error)
29+
PriorityFee(ctx context.Context) (*big.Int, error)
30+
31+
Subscribe(ctx context.Context) (<-chan Diff, error)
32+
}
33+
34+
type Diff struct {
35+
Removed, Added map[common.Hash]struct{}
36+
}
37+
38+
type GasGaugeSpeed int
39+
40+
const (
41+
GasGaugeSpeedDefault GasGaugeSpeed = iota
42+
GasGaugeSpeedSlow
43+
GasGaugeSpeedStandard
44+
GasGaugeSpeedFast
45+
GasGaugeSpeedInstant
46+
)
47+
48+
type EthkitChainOptions struct {
49+
ChainID *big.Int
50+
IsEIP1559 bool
51+
52+
// Provider is an ethkit Provider, required.
53+
Provider *ethrpc.Provider
54+
// Monitor is a running ethkit Monitor, required.
55+
Monitor *ethmonitor.Monitor
56+
// GasGauge is a running ethkit GasGauge, required only for non-EIP-1559 chains.
57+
GasGauge *ethgas.GasGauge
58+
// GasGaugeSpeed defaults to GasGaugeSpeedDefault (GasGaugeSpeedFast), unused for EIP-1559 chains.
59+
GasGaugeSpeed GasGaugeSpeed
60+
// Logger is used to log chain behaviour, optional.
61+
Logger *slog.Logger
62+
63+
PriorityFee *big.Int
64+
}
65+
66+
func (o EthkitChainOptions) IsValid() error {
67+
if o.ChainID == nil {
68+
return fmt.Errorf("no chain id")
69+
} else if o.ChainID.Sign() <= 0 {
70+
return fmt.Errorf("non-positive chain id %v", o.ChainID)
71+
}
72+
73+
if o.Provider == nil {
74+
return fmt.Errorf("no provider")
75+
}
76+
77+
if o.Monitor == nil {
78+
return fmt.Errorf("no monitor")
79+
}
80+
81+
if !o.IsEIP1559 && o.GasGauge == nil {
82+
return fmt.Errorf("no gas gauge")
83+
}
84+
85+
if o.GasGaugeSpeed < GasGaugeSpeedDefault || o.GasGaugeSpeed > GasGaugeSpeedInstant {
86+
return fmt.Errorf("invalid gas gauge speed %v", o.GasGaugeSpeed)
87+
}
88+
89+
if o.PriorityFee != nil && o.PriorityFee.Sign() < 0 {
90+
return fmt.Errorf("negative priority fee %v", o.PriorityFee)
91+
}
92+
93+
return nil
94+
}
95+
96+
type ethkitChain struct {
97+
EthkitChainOptions
98+
99+
baseFee, priorityFee *big.Int
100+
mu sync.Mutex
101+
}
102+
103+
// NewEthkitChain creates a Chain using ethkit components.
104+
func NewEthkitChain(options EthkitChainOptions) (Chain, error) {
105+
if err := options.IsValid(); err != nil {
106+
return nil, err
107+
}
108+
109+
if options.Logger == nil {
110+
options.Logger = slog.New(slog.DiscardHandler)
111+
}
112+
113+
return &ethkitChain{EthkitChainOptions: options}, nil
114+
}
115+
116+
func (c *ethkitChain) ChainID() *big.Int {
117+
return new(big.Int).Set(c.EthkitChainOptions.ChainID)
118+
}
119+
120+
func (c *ethkitChain) IsEIP1559() bool {
121+
return c.EthkitChainOptions.IsEIP1559
122+
}
123+
124+
func (c *ethkitChain) LatestNonce(ctx context.Context, address common.Address) (uint64, error) {
125+
return c.Provider.NonceAt(ctx, address, nil)
126+
}
127+
128+
func (c *ethkitChain) PendingNonce(ctx context.Context, address common.Address) (uint64, error) {
129+
return c.Provider.PendingNonceAt(ctx, address)
130+
}
131+
132+
func (c *ethkitChain) Send(ctx context.Context, transaction *types.Transaction) error {
133+
return c.Provider.SendTransaction(ctx, transaction)
134+
}
135+
136+
func (c *ethkitChain) GasPrice(ctx context.Context) (*big.Int, error) {
137+
switch c.GasGaugeSpeed {
138+
case GasGaugeSpeedSlow:
139+
return new(big.Int).Set(c.GasGauge.SuggestedGasPrice().SlowWei), nil
140+
case GasGaugeSpeedStandard:
141+
return new(big.Int).Set(c.GasGauge.SuggestedGasPrice().StandardWei), nil
142+
case GasGaugeSpeedFast:
143+
return new(big.Int).Set(c.GasGauge.SuggestedGasPrice().FastWei), nil
144+
case GasGaugeSpeedInstant:
145+
return new(big.Int).Set(c.GasGauge.SuggestedGasPrice().InstantWei), nil
146+
default:
147+
return new(big.Int).Set(c.GasGauge.SuggestedGasPrice().FastWei), nil
148+
}
149+
}
150+
151+
func (c *ethkitChain) BaseFee(ctx context.Context) (*big.Int, error) {
152+
block, err := c.Provider.BlockByNumber(ctx, nil)
153+
if err != nil {
154+
return nil, fmt.Errorf("unable to get latest block: %w", err)
155+
}
156+
157+
baseFee := block.BaseFee()
158+
if baseFee == nil {
159+
return nil, fmt.Errorf("no base fee")
160+
}
161+
162+
c.mu.Lock()
163+
defer c.mu.Unlock()
164+
165+
if c.baseFee == nil || baseFee.Cmp(c.baseFee) != 0 {
166+
c.Logger.DebugContext(ctx, "base fee", slog.String("baseFee", baseFee.String()), slog.String("block", block.Number().String()))
167+
c.baseFee = new(big.Int).Set(baseFee)
168+
}
169+
170+
return baseFee, nil
171+
}
172+
173+
func (c *ethkitChain) PriorityFee(ctx context.Context) (*big.Int, error) {
174+
priorityFee := new(big.Int)
175+
if c.EthkitChainOptions.PriorityFee != nil {
176+
priorityFee.Set(c.EthkitChainOptions.PriorityFee)
177+
}
178+
179+
c.mu.Lock()
180+
defer c.mu.Unlock()
181+
182+
if c.priorityFee == nil || priorityFee.Cmp(c.priorityFee) != 0 {
183+
c.Logger.DebugContext(ctx, "priority fee", slog.String("priorityFee", priorityFee.String()))
184+
c.priorityFee = new(big.Int).Set(priorityFee)
185+
}
186+
187+
return priorityFee, nil
188+
}
189+
190+
func (c *ethkitChain) Subscribe(ctx context.Context) (<-chan Diff, error) {
191+
diffs := make(chan Diff)
192+
193+
go func() {
194+
defer close(diffs)
195+
196+
subscription := c.Monitor.Subscribe()
197+
198+
for {
199+
select {
200+
case <-ctx.Done():
201+
subscription.Unsubscribe()
202+
return
203+
204+
case <-subscription.Done():
205+
return
206+
207+
case blocks, ok := <-subscription.Blocks():
208+
if !ok {
209+
return
210+
}
211+
212+
diff := Diff{
213+
Removed: map[common.Hash]struct{}{},
214+
Added: map[common.Hash]struct{}{},
215+
}
216+
217+
for _, block := range blocks {
218+
switch block.Event {
219+
case ethmonitor.Added:
220+
c.Logger.DebugContext(
221+
ctx,
222+
"block mined",
223+
slog.String("block", block.Hash().String()),
224+
slog.String("number", block.Number().String()),
225+
slog.Int("transactions", block.Transactions().Len()),
226+
)
227+
228+
for _, transaction := range block.Transactions() {
229+
diff.Added[transaction.Hash()] = struct{}{}
230+
}
231+
232+
case ethmonitor.Removed:
233+
c.Logger.DebugContext(
234+
ctx,
235+
"block reorged",
236+
slog.String("block", block.Hash().String()),
237+
slog.String("number", block.Number().String()),
238+
slog.Int("transactions", block.Transactions().Len()),
239+
)
240+
241+
for _, transaction := range block.Transactions() {
242+
diff.Removed[transaction.Hash()] = struct{}{}
243+
}
244+
}
245+
}
246+
247+
select {
248+
case <-ctx.Done():
249+
subscription.Unsubscribe()
250+
return
251+
252+
case <-subscription.Done():
253+
return
254+
255+
case diffs <- diff:
256+
}
257+
}
258+
}
259+
}()
260+
261+
return diffs, nil
262+
}

0 commit comments

Comments
 (0)