Skip to content

Commit 72f37f6

Browse files
authored
Support external signers in ethfinalizer (#218)
* Support external transaction signers in ethfinalizer * Reject nil wallets through finalizer signer validation
1 parent 74d664f commit 72f37f6

3 files changed

Lines changed: 199 additions & 7 deletions

File tree

‎ethfinalizer/ethfinalizer.go‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ import (
2222
// Type parameters:
2323
// - T: transaction metadata type
2424
type FinalizerOptions[T any] struct {
25-
// Wallet is the wallet to be managed by this finalizer, required.
25+
// Wallet is the local wallet to manage. Set exactly one of Wallet or Signer.
2626
Wallet *ethwallet.Wallet
27+
// Signer signs transactions, including replacements, using the operation context.
28+
Signer Signer
2729
// Chain is the provider for the chain where transactions will be sent, required.
2830
// See NewEthkitChain for an implementation using ethkit components.
2931
Chain Chain
@@ -63,8 +65,8 @@ type FinalizerOptions[T any] struct {
6365
}
6466

6567
func (o FinalizerOptions[T]) IsValid() error {
66-
if o.Wallet == nil {
67-
return fmt.Errorf("no wallet")
68+
if (o.Wallet == nil) == (o.Signer == nil) {
69+
return fmt.Errorf("exactly one of wallet or signer is required")
6870
}
6971

7072
if o.Chain == nil {
@@ -116,6 +118,7 @@ func (o FinalizerOptions[T]) IsValid() error {
116118
// - T: transaction metadata type
117119
type Finalizer[T any] struct {
118120
FinalizerOptions[T]
121+
signer Signer
119122

120123
isRunning, isStuck atomic.Bool
121124

@@ -173,7 +176,13 @@ func NewFinalizer[T any](options FinalizerOptions[T]) (*Finalizer[T], error) {
173176
options.Logger = slog.New(slog.DiscardHandler)
174177
}
175178

179+
signer := options.Signer
180+
if signer == nil {
181+
signer = NewWalletSigner(options.Wallet)
182+
}
183+
176184
return &Finalizer[T]{
185+
signer: signer,
177186
FinalizerOptions: options,
178187

179188
subscriptions: map[chan Event[T]]struct{}{},
@@ -280,7 +289,7 @@ func (f *Finalizer[T]) Run(ctx context.Context) error {
280289

281290
f.Logger.DebugContext(ctx, "polling", slog.Duration("interval", f.PollInterval), slog.Duration("timeout", f.PollTimeout))
282291

283-
chainNonce, err := f.Chain.LatestNonce(ctx, f.Wallet.Address())
292+
chainNonce, err := f.Chain.LatestNonce(ctx, f.signer.Address())
284293
if err != nil {
285294
return fmt.Errorf("unable to read chain nonce: %w", err)
286295
}
@@ -494,7 +503,7 @@ func (f *Finalizer[T]) Run(ctx context.Context) error {
494503
f.Logger.ErrorContext(ctx, "unable to resend transaction to chain", slog.Any("error", err), slog.String("transaction", transaction.Hash().String()))
495504
}
496505
} else {
497-
if replacement, err = f.Wallet.SignTransaction(replacement, f.Chain.ChainID()); err == nil {
506+
if replacement, err = f.signer.SignTransaction(ctx, replacement, f.Chain.ChainID()); err == nil {
498507
if err := f.Mempool.Commit(ctx, replacement, transaction.Metadata); err != nil {
499508
f.Logger.ErrorContext(ctx, "unable to commit replacement transaction to mempool", slog.Any("error", err))
500509
continue
@@ -666,7 +675,7 @@ func (f *Finalizer[T]) Send(ctx context.Context, transaction *types.Transaction,
666675
return nil, fmt.Errorf("unable to read mempool nonce: %w", err)
667676
}
668677

669-
chainNonce, err := f.Chain.PendingNonce(ctx, f.Wallet.Address())
678+
chainNonce, err := f.Chain.PendingNonce(ctx, f.signer.Address())
670679
if err != nil {
671680
return nil, fmt.Errorf("unable to read chain nonce: %w", err)
672681
}
@@ -677,7 +686,7 @@ func (f *Finalizer[T]) Send(ctx context.Context, transaction *types.Transaction,
677686

678687
transaction = withNonce(transaction, max(mempoolNonce, chainNonce))
679688

680-
transaction, err = f.Wallet.SignTransaction(transaction, f.Chain.ChainID())
689+
transaction, err = f.signer.SignTransaction(ctx, transaction, f.Chain.ChainID())
681690
if err != nil {
682691
return nil, fmt.Errorf("unable to sign transaction: %w", err)
683692
}

‎ethfinalizer/signer.go‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package ethfinalizer
2+
3+
import (
4+
"context"
5+
"math/big"
6+
7+
"github.com/0xsequence/ethkit/ethwallet"
8+
"github.com/0xsequence/ethkit/go-ethereum/common"
9+
"github.com/0xsequence/ethkit/go-ethereum/core/types"
10+
)
11+
12+
// Signer signs transactions without requiring access to private key material.
13+
// Implementations must respect cancellation and return a transaction signed by Address.
14+
type Signer interface {
15+
Address() common.Address
16+
SignTransaction(context.Context, *types.Transaction, *big.Int) (*types.Transaction, error)
17+
}
18+
19+
type walletSigner struct{ wallet *ethwallet.Wallet }
20+
21+
// NewWalletSigner adapts a local wallet to Signer. A nil wallet returns a nil Signer.
22+
func NewWalletSigner(wallet *ethwallet.Wallet) Signer {
23+
if wallet == nil {
24+
return nil
25+
}
26+
return &walletSigner{wallet: wallet}
27+
}
28+
29+
func (s *walletSigner) Address() common.Address { return s.wallet.Address() }
30+
31+
func (s *walletSigner) SignTransaction(ctx context.Context, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) {
32+
if err := ctx.Err(); err != nil {
33+
return nil, err
34+
}
35+
return s.wallet.SignTransaction(tx, chainID)
36+
}

‎ethfinalizer/signer_test.go‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package ethfinalizer
2+
3+
import (
4+
"context"
5+
"errors"
6+
"math/big"
7+
"sync/atomic"
8+
"testing"
9+
"time"
10+
11+
"github.com/0xsequence/ethkit/ethwallet"
12+
"github.com/0xsequence/ethkit/go-ethereum/common"
13+
"github.com/0xsequence/ethkit/go-ethereum/core/types"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
type externalTestSigner struct {
18+
Signer
19+
fail atomic.Bool
20+
calls atomic.Int32
21+
}
22+
23+
func (s *externalTestSigner) SignTransaction(ctx context.Context, tx *types.Transaction, chain *big.Int) (*types.Transaction, error) {
24+
s.calls.Add(1)
25+
if s.fail.Load() {
26+
return nil, errors.New("signer unavailable")
27+
}
28+
return s.Signer.SignTransaction(ctx, tx, chain)
29+
}
30+
31+
type externalTestChain struct {
32+
gas atomic.Int64
33+
sent chan *types.Transaction
34+
}
35+
36+
func (c *externalTestChain) ChainID() *big.Int { return big.NewInt(1) }
37+
func (c *externalTestChain) IsEIP1559() bool { return false }
38+
func (c *externalTestChain) LatestNonce(context.Context, common.Address) (uint64, error) {
39+
return 0, nil
40+
}
41+
func (c *externalTestChain) PendingNonce(context.Context, common.Address) (uint64, error) {
42+
return 0, nil
43+
}
44+
func (c *externalTestChain) GasPrice(context.Context) (*big.Int, error) {
45+
return big.NewInt(c.gas.Load()), nil
46+
}
47+
func (c *externalTestChain) BaseFee(context.Context) (*big.Int, error) { return nil, nil }
48+
func (c *externalTestChain) PriorityFee(context.Context) (*big.Int, error) { return nil, nil }
49+
func (c *externalTestChain) Send(ctx context.Context, tx *types.Transaction) error {
50+
select {
51+
case c.sent <- tx:
52+
return nil
53+
case <-ctx.Done():
54+
return ctx.Err()
55+
}
56+
}
57+
func (c *externalTestChain) Subscribe(ctx context.Context) (<-chan Diff, error) {
58+
ch := make(chan Diff)
59+
go func() { <-ctx.Done(); close(ch) }()
60+
return ch, nil
61+
}
62+
63+
func TestNewWalletSignerNil(t *testing.T) {
64+
signer := NewWalletSigner(nil)
65+
require.True(t, signer == nil, "nil wallet must produce a nil interface")
66+
67+
finalizer, err := NewFinalizer(FinalizerOptions[string]{
68+
Signer: signer,
69+
Chain: &externalTestChain{},
70+
Mempool: NewMemoryMempool[string](),
71+
PollInterval: time.Second,
72+
PollTimeout: time.Second,
73+
RetryDelay: time.Second,
74+
})
75+
require.ErrorContains(t, err, "exactly one of wallet or signer is required")
76+
require.Nil(t, finalizer)
77+
}
78+
79+
func TestExternalSignerLifecycle(t *testing.T) {
80+
wallet, err := ethwallet.NewWalletFromRandomEntropy()
81+
require.NoError(t, err)
82+
signer := &externalTestSigner{Signer: NewWalletSigner(wallet)}
83+
chain := &externalTestChain{sent: make(chan *types.Transaction, 100)}
84+
chain.gas.Store(10)
85+
mempool := NewMemoryMempool[string]()
86+
options := FinalizerOptions[string]{Signer: signer, Chain: chain, Mempool: mempool, PollInterval: 5 * time.Millisecond, PollTimeout: time.Second, RetryDelay: time.Millisecond, PriceBump: 15}
87+
f, err := NewFinalizer(options)
88+
require.NoError(t, err)
89+
options.Wallet = wallet
90+
_, err = NewFinalizer(options)
91+
require.Error(t, err)
92+
options.Signer = nil
93+
_, err = NewFinalizer(options)
94+
require.NoError(t, err)
95+
options.Wallet = nil
96+
_, err = NewFinalizer(options)
97+
require.Error(t, err)
98+
tx := types.NewTx(&types.LegacyTx{Gas: 21000, GasPrice: big.NewInt(10)})
99+
signer.fail.Store(true)
100+
_, err = f.Send(context.Background(), tx, "metadata")
101+
require.Error(t, err)
102+
nonce, err := mempool.Nonce(context.Background())
103+
require.NoError(t, err)
104+
require.Zero(t, nonce)
105+
require.Empty(t, chain.sent)
106+
signer.fail.Store(false)
107+
ctx, cancel := context.WithCancel(context.Background())
108+
cancel()
109+
_, err = f.Send(ctx, tx, "metadata")
110+
require.ErrorIs(t, err, context.Canceled)
111+
signed, err := f.Send(context.Background(), tx, "metadata")
112+
require.NoError(t, err)
113+
require.Equal(t, signed.Hash(), (<-chain.sent).Hash())
114+
calls := signer.calls.Load()
115+
ctx, cancel = context.WithCancel(context.Background())
116+
defer cancel()
117+
done := make(chan error, 1)
118+
go func() { done <- f.Run(ctx) }()
119+
select {
120+
case resent := <-chain.sent:
121+
require.Equal(t, signed.Hash(), resent.Hash())
122+
case <-time.After(time.Second):
123+
t.Fatal("no rebroadcast")
124+
}
125+
require.Equal(t, calls, signer.calls.Load(), "rebroadcast must reuse persisted signature")
126+
signer.fail.Store(true)
127+
chain.gas.Store(20)
128+
require.Eventually(t, func() bool { return signer.calls.Load() > calls }, time.Second, time.Millisecond)
129+
_, latest, err := mempool.Status(context.Background(), 0)
130+
require.NoError(t, err)
131+
require.Equal(t, signed.Hash(), latest.Transaction.Hash())
132+
signer.fail.Store(false)
133+
require.Eventually(t, func() bool {
134+
_, latest, err := mempool.Status(context.Background(), 0)
135+
return err == nil && latest.Transaction.GasPrice().Int64() == 20
136+
}, time.Second, time.Millisecond)
137+
_, latest, err = mempool.Status(context.Background(), 0)
138+
require.NoError(t, err)
139+
require.Equal(t, "metadata", latest.Transaction.Metadata)
140+
require.Zero(t, latest.Transaction.Nonce())
141+
cancel()
142+
select {
143+
case <-done:
144+
case <-time.After(time.Second):
145+
t.Fatal("finalizer did not stop")
146+
}
147+
}

0 commit comments

Comments
 (0)