Skip to content
This repository was archived by the owner on Feb 8, 2026. It is now read-only.

Commit 4bb281b

Browse files
author
Garry Sharp
committed
✨ Resend stuck txs if nonce is the same as pending
1 parent 15a008c commit 4bb281b

11 files changed

Lines changed: 362 additions & 95 deletions

File tree

etc/vultisig/fee.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ jobs:
1313
max_concurrent_jobs: 10
1414
post:
1515
cronexpr: "@every 5m"
16-
max_concurrent_jobs: 10
16+
max_concurrent_jobs: 10
17+
dry_run: false

internal/types/fees.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,12 @@ type FeeRun struct {
3737
FeeCount int `db:"fee_count"`
3838
Fees []Fee `db:"fees"`
3939
}
40+
41+
type FeeRunTx struct {
42+
ID uuid.UUID `db:"id"`
43+
FeeRunID uuid.UUID `db:"fee_run_id"`
44+
Tx string `db:"tx"`
45+
Hash string `db:"hash"`
46+
BlockNumber uint64 `db:"block_number"`
47+
CreatedAt time.Time `db:"created_at"`
48+
}

plugin/fees/config.go

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,13 @@ import (
1111

1212
// These are properties and parameters specific to the fee plugin config. They should be distinct from system/core config
1313
type FeeConfig struct {
14-
Type string `mapstructure:"type"`
15-
Version string `mapstructure:"version"`
16-
MaxFeeAmount uint64 `mapstructure:"max_fee_amount"` // Policies that are created/submitted which do not have this amount will be rejected.
17-
UsdcAddress string `mapstructure:"usdc_address"` // The address of the USDC token on the Ethereum blockchain.
18-
VerifierToken string `mapstructure:"verifier_token"` // The token to use for the verifier API.
19-
chainId uint64 `mapstructure:"chain_id"` // The chain ID of the Ethereum blockchain.
20-
ChainId *big.Int
21-
EthProvider string `mapstructure:"eth_provider"` // The Ethereum provider to use for the fee plugin.
14+
Type string `mapstructure:"type"`
15+
Version string `mapstructure:"version"`
16+
MaxFeeAmount uint64 `mapstructure:"max_fee_amount"` // Policies that are created/submitted which do not have this amount will be rejected.
17+
UsdcAddress string `mapstructure:"usdc_address"` // The address of the USDC token on the Ethereum blockchain.
18+
VerifierToken string `mapstructure:"verifier_token"` // The token to use for the verifier API.
19+
ChainId *big.Int // The chain ID as a big.Int (initialized from ChainIdRaw).
20+
EthProvider string `mapstructure:"eth_provider"` // The Ethereum provider to use for the fee plugin.
2221
Jobs struct {
2322
Load struct {
2423
MaxConcurrentJobs uint64 `mapstructure:"max_concurrent_jobs"` //How many consecutive tasks can take place
@@ -34,6 +33,12 @@ type FeeConfig struct {
3433
MaxConcurrentJobs uint64 `mapstructure:"max_concurrent_jobs"`
3534
} `mapstructure:"post"`
3635
}
36+
DryRun bool `mapstructure:"dry_run"`
37+
}
38+
39+
type FeeConfigFileWrapper struct {
40+
FeeConfig `mapstructure:",squash"`
41+
ChainIdRaw uint64 `mapstructure:"chain_id"`
3742
}
3843

3944
type ConfigOption func(*FeeConfig) error
@@ -52,6 +57,7 @@ func withDefaults(c *FeeConfig) {
5257
c.Jobs.Load.Cronexpr = "@every 2m"
5358
c.Jobs.Transact.Cronexpr = "0 12 * * 5"
5459
c.Jobs.Post.Cronexpr = "@every 5m"
60+
c.DryRun = false
5561
}
5662

5763
func WithMaxFeeAmount(maxFeeAmount uint64) ConfigOption {
@@ -100,9 +106,16 @@ func WithCronexpr(load, transact, post string) ConfigOption {
100106
}
101107
}
102108

103-
func WithFileConfig(basePath string) ConfigOption {
109+
func WithDryRun(dryRun bool) ConfigOption {
104110
return func(c *FeeConfig) error {
111+
c.DryRun = dryRun
112+
return nil
113+
}
114+
}
115+
116+
func WithFileConfig(basePath string) ConfigOption {
105117

118+
return func(c *FeeConfig) error {
106119
v := viper.New()
107120
v.SetConfigName("fee")
108121

@@ -122,11 +135,15 @@ func WithFileConfig(basePath string) ConfigOption {
122135
return fmt.Errorf("failed to read config: %w", err)
123136
}
124137

125-
if err := v.Unmarshal(c); err != nil {
138+
var wrappedConfig FeeConfigFileWrapper
139+
if err := v.Unmarshal(&wrappedConfig); err != nil {
126140
return fmt.Errorf("failed to unmarshal config: %w", err)
127141
}
128142

129-
c.ChainId = big.NewInt(0).SetUint64(c.chainId)
143+
// Copy all values from the wrapped config to the original config
144+
*c = wrappedConfig.FeeConfig
145+
146+
c.ChainId = big.NewInt(0).SetUint64(wrappedConfig.ChainIdRaw)
130147
return nil
131148
}
132149
}
@@ -149,8 +166,8 @@ func NewFeeConfig(fns ...ConfigOption) (*FeeConfig, error) {
149166
return c, errors.New("verifier_token is required")
150167
}
151168

152-
if c.ChainId == nil {
153-
return c, errors.New("chain_id is required")
169+
if c.ChainId == nil || c.ChainId.Uint64() == 0 {
170+
return c, errors.New("chain_id is required and must not be 0")
154171
}
155172

156173
if c.EthProvider == "" {

plugin/fees/fees.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,9 +203,26 @@ func (fp *FeePlugin) executeFeeLoading(ctx context.Context, feePolicy vtypes.Plu
203203
}
204204

205205
// If no draft run is found, create a new one and add the fee to it
206+
dbTx, err := fp.db.Pool().Begin(ctx)
207+
if err != nil {
208+
return fmt.Errorf("failed to begin transaction: %w", err)
209+
}
210+
211+
var txErr error
212+
defer func() {
213+
if txErr != nil {
214+
if err := dbTx.Rollback(ctx); err != nil {
215+
fp.logger.WithError(err).Error("Failed to rollback transaction")
216+
}
217+
} else {
218+
if err := dbTx.Commit(ctx); err != nil {
219+
fp.logger.WithError(err).Error("Failed to commit transaction")
220+
}
221+
}
222+
}()
206223
if run == nil {
207-
run, err = fp.db.CreateFeeRun(ctx, feePolicy.ID, types.FeeRunStateDraft, fee)
208-
if err != nil {
224+
run, txErr = fp.db.CreateFeeRun(ctx, dbTx, feePolicy.ID, types.FeeRunStateDraft, fee)
225+
if txErr != nil {
209226
return fmt.Errorf("failed to create fee run: %w", err)
210227
}
211228
fp.logger.WithFields(logrus.Fields{
@@ -216,7 +233,7 @@ func (fp *FeePlugin) executeFeeLoading(ctx context.Context, feePolicy vtypes.Plu
216233

217234
// If a draft run is found, add the fee to it
218235
} else {
219-
if err := fp.db.CreateFee(ctx, run.ID, fee); err != nil {
236+
if txErr = fp.db.CreateFee(ctx, dbTx, run.ID, fee); err != nil {
220237
return fmt.Errorf("failed to create fee: %w", err)
221238
}
222239
fp.logger.WithFields(logrus.Fields{
@@ -225,6 +242,7 @@ func (fp *FeePlugin) executeFeeLoading(ctx context.Context, feePolicy vtypes.Plu
225242
"runId": run.ID,
226243
}).Info("Fee added to fee run")
227244
}
245+
228246
}
229247
}
230248

plugin/fees/helper.go

Lines changed: 90 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package fees
22

33
import (
4+
"context"
45
"encoding/hex"
56
"fmt"
67
"math/big"
@@ -11,8 +12,13 @@ import (
1112
"github.com/ethereum/go-ethereum/common/hexutil"
1213
etypes "github.com/ethereum/go-ethereum/core/types"
1314
"github.com/ethereum/go-ethereum/rlp"
15+
"github.com/google/uuid"
16+
"github.com/vultisig/plugin/common"
1417
reth "github.com/vultisig/recipes/ethereum"
1518
"github.com/vultisig/recipes/sdk/evm"
19+
"github.com/vultisig/verifier/address"
20+
vcommon "github.com/vultisig/verifier/common"
21+
vtypes "github.com/vultisig/verifier/types"
1622
)
1723

1824
func getHash(inTx evm.UnsignedTx, r, s, v []byte, chainID *big.Int) (*etypes.Transaction, error) {
@@ -34,76 +40,87 @@ func getHash(inTx evm.UnsignedTx, r, s, v []byte, chainID *big.Int) (*etypes.Tra
3440
return outTx, nil
3541
}
3642

37-
type erc20tx struct {
43+
type erc20Data struct {
3844
to ecommon.Address `json:"to"`
3945
amount *big.Int `json:"amount"`
4046
token ecommon.Address `json:"token"`
4147
}
4248

43-
func decodeTx(rawHex string) (*erc20tx, error) {
44-
type unsignedDynamicFeeTx struct {
45-
ChainID *big.Int
46-
Nonce uint64
47-
GasTipCap *big.Int
48-
GasFeeCap *big.Int
49-
Gas uint64
50-
To *ecommon.Address
51-
Value *big.Int
52-
Data []byte
53-
AccessList etypes.AccessList
54-
}
49+
type unsignedDynamicFeeTx struct {
50+
ChainID *big.Int
51+
Nonce uint64
52+
GasTipCap *big.Int
53+
GasFeeCap *big.Int
54+
Gas uint64
55+
To *ecommon.Address
56+
Value *big.Int
57+
Data []byte
58+
AccessList etypes.AccessList
59+
}
5560

61+
func decodeUnsignedTx(rawHex string) (*unsignedDynamicFeeTx, error) {
5662
rawHex = strings.TrimPrefix(rawHex, "0x")
57-
5863
rawBytes, err := hex.DecodeString(rawHex)
5964
if err != nil {
6065
return nil, fmt.Errorf("hex decode failed: %w", err)
6166
}
6267

63-
// Check transaction type (EIP-1559 is 0x02)
6468
if len(rawBytes) == 0 || rawBytes[0] != 0x02 {
6569
return nil, fmt.Errorf("unsupported transaction type: 0x%02x", rawBytes[0])
6670
}
6771

72+
// Decode RLP (strip type byte)
6873
tx := new(unsignedDynamicFeeTx)
69-
err = rlp.DecodeBytes(rawBytes[1:], tx)
70-
if err != nil {
74+
if err := rlp.DecodeBytes(rawBytes[1:], tx); err != nil {
7175
return nil, fmt.Errorf("rlp decode failed: %w", err)
7276
}
7377

74-
// Parse the ERC20 transfer ABI
78+
return tx, nil
79+
}
80+
81+
// pass in a types.Transaction or a unsignedDynamicFeeTx
82+
func parseErc20Tx[T any](transaction T) (*erc20Data, error) {
83+
// Decode ERC20 transfer ABI
7584
const transferABI = `[{"name":"transfer","type":"function","inputs":[{"name":"to","type":"address"},{"name":"value","type":"uint256"}],"outputs":[{"name":"","type":"bool"}]}]`
76-
parsedABI, err := abi.JSON(strings.NewReader(transferABI))
77-
if err != nil {
78-
return nil, fmt.Errorf("failed to parse ABI")
85+
parsedABI, _ := abi.JSON(strings.NewReader(transferABI))
86+
87+
var data []byte
88+
var to *ecommon.Address
89+
90+
switch tx := any(transaction).(type) {
91+
case *etypes.Transaction:
92+
data = tx.Data()
93+
to = tx.To()
94+
case *unsignedDynamicFeeTx:
95+
data = tx.Data
96+
to = tx.To
97+
default:
98+
return nil, fmt.Errorf("unsupported transaction struct type")
7999
}
80100

81-
// Get the method by selector
82-
method, err := parsedABI.MethodById(tx.Data[:4])
101+
method, err := parsedABI.MethodById(data[:4])
83102
if err != nil {
84103
return nil, fmt.Errorf("unknown method ID")
85104
}
86105

87-
// Decode the arguments
88106
args := make(map[string]interface{})
89-
if err := method.Inputs.UnpackIntoMap(args, tx.Data[4:]); err != nil {
90-
return nil, fmt.Errorf("failed get recipient and amount from tx")
107+
if err := method.Inputs.UnpackIntoMap(args, data[4:]); err != nil {
108+
return nil, fmt.Errorf("failed to unpack args")
91109
}
92110

93111
recipient, ok := args["to"].(ecommon.Address)
94112
if !ok {
95-
return nil, fmt.Errorf("invalid recipient address in tx data")
113+
return nil, fmt.Errorf("invalid recipient type")
96114
}
97-
98115
amount, ok := args["value"].(*big.Int)
99116
if !ok {
100-
return nil, fmt.Errorf("invalid amount in tx data")
117+
return nil, fmt.Errorf("invalid amount type")
101118
}
102119

103-
return &erc20tx{
120+
return &erc20Data{
104121
to: recipient,
105122
amount: amount,
106-
token: *tx.To,
123+
token: *to,
107124
}, nil
108125
}
109126

@@ -113,3 +130,45 @@ func hexutilDecode(hexStr string) ([]byte, error) {
113130
}
114131
return hexutil.Decode(hexStr)
115132
}
133+
134+
func appendSignature(inTx evm.UnsignedTx, r, s, v []byte, chainID *big.Int) (*etypes.Transaction, error) {
135+
var sig []byte
136+
sig = append(sig, r...)
137+
sig = append(sig, s...)
138+
sig = append(sig, v...)
139+
140+
inTxDecoded, err := reth.DecodeUnsignedPayload(inTx)
141+
if err != nil {
142+
return nil, fmt.Errorf("reth.DecodeUnsignedPayload: %w", err)
143+
}
144+
145+
outTx, err := etypes.NewTx(inTxDecoded).WithSignature(etypes.LatestSignerForChainID(chainID), sig)
146+
if err != nil {
147+
return nil, fmt.Errorf("types.NewTx.WithSignature: %w", err)
148+
}
149+
150+
return outTx, nil
151+
}
152+
153+
func (fp *FeePlugin) getEthAddressFromFeePolicy(policy vtypes.PluginPolicy) (string, error) {
154+
vault, err := common.GetVaultFromPolicy(fp.vaultStorage, policy, fp.encryptionSecret)
155+
if err != nil {
156+
return "", fmt.Errorf("failed to get vault from policy: %v", err)
157+
}
158+
159+
// Get the ethereum derived addresses from the vaults master public key
160+
fromAddress, _, _, err := address.GetAddress(vault.PublicKeyEcdsa, vault.HexChainCode, vcommon.Ethereum)
161+
if err != nil {
162+
return "", fmt.Errorf("failed to get eth address: %v", err)
163+
}
164+
165+
return fromAddress, nil
166+
}
167+
168+
func (fp *FeePlugin) getEthAddressFromFeePolicyId(policyId uuid.UUID) (string, error) {
169+
policy, err := fp.db.GetPluginPolicy(context.Background(), policyId)
170+
if err != nil {
171+
return "", fmt.Errorf("failed to get policy: %v", err)
172+
}
173+
return fp.getEthAddressFromFeePolicy(*policy)
174+
}

0 commit comments

Comments
 (0)