Skip to content
This repository was archived by the owner on Feb 8, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion etc/vultisig/fee.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ jobs:
max_concurrent_jobs: 10
post:
cronexpr: "@every 5m"
max_concurrent_jobs: 10
max_concurrent_jobs: 10
dry_run: false
9 changes: 9 additions & 0 deletions internal/types/fees.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,12 @@ type FeeRun struct {
FeeCount int `db:"fee_count"`
Fees []Fee `db:"fees"`
}

type FeeRunTx struct {
ID uuid.UUID `db:"id"`
FeeRunID uuid.UUID `db:"fee_run_id"`
Tx string `db:"tx"`
Hash string `db:"hash"`
BlockNumber uint64 `db:"block_number"`
CreatedAt time.Time `db:"created_at"`
}
43 changes: 30 additions & 13 deletions plugin/fees/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,13 @@ import (

// These are properties and parameters specific to the fee plugin config. They should be distinct from system/core config
type FeeConfig struct {
Type string `mapstructure:"type"`
Version string `mapstructure:"version"`
MaxFeeAmount uint64 `mapstructure:"max_fee_amount"` // Policies that are created/submitted which do not have this amount will be rejected.
UsdcAddress string `mapstructure:"usdc_address"` // The address of the USDC token on the Ethereum blockchain.
VerifierToken string `mapstructure:"verifier_token"` // The token to use for the verifier API.
chainId uint64 `mapstructure:"chain_id"` // The chain ID of the Ethereum blockchain.
ChainId *big.Int
EthProvider string `mapstructure:"eth_provider"` // The Ethereum provider to use for the fee plugin.
Type string `mapstructure:"type"`
Version string `mapstructure:"version"`
MaxFeeAmount uint64 `mapstructure:"max_fee_amount"` // Policies that are created/submitted which do not have this amount will be rejected.
UsdcAddress string `mapstructure:"usdc_address"` // The address of the USDC token on the Ethereum blockchain.
VerifierToken string `mapstructure:"verifier_token"` // The token to use for the verifier API.
ChainId *big.Int // The chain ID as a big.Int (initialized from ChainIdRaw).
EthProvider string `mapstructure:"eth_provider"` // The Ethereum provider to use for the fee plugin.
Jobs struct {
Load struct {
MaxConcurrentJobs uint64 `mapstructure:"max_concurrent_jobs"` //How many consecutive tasks can take place
Expand All @@ -34,6 +33,12 @@ type FeeConfig struct {
MaxConcurrentJobs uint64 `mapstructure:"max_concurrent_jobs"`
} `mapstructure:"post"`
}
DryRun bool `mapstructure:"dry_run"`
}

type FeeConfigFileWrapper struct {
FeeConfig `mapstructure:",squash"`
ChainIdRaw uint64 `mapstructure:"chain_id"`
}

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

func WithMaxFeeAmount(maxFeeAmount uint64) ConfigOption {
Expand Down Expand Up @@ -100,9 +106,16 @@ func WithCronexpr(load, transact, post string) ConfigOption {
}
}

func WithFileConfig(basePath string) ConfigOption {
func WithDryRun(dryRun bool) ConfigOption {
return func(c *FeeConfig) error {
c.DryRun = dryRun
return nil
}
}

func WithFileConfig(basePath string) ConfigOption {

return func(c *FeeConfig) error {
v := viper.New()
v.SetConfigName("fee")

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

if err := v.Unmarshal(c); err != nil {
var wrappedConfig FeeConfigFileWrapper
if err := v.Unmarshal(&wrappedConfig); err != nil {
return fmt.Errorf("failed to unmarshal config: %w", err)
}

c.ChainId = big.NewInt(0).SetUint64(c.chainId)
// Copy all values from the wrapped config to the original config
*c = wrappedConfig.FeeConfig

c.ChainId = big.NewInt(0).SetUint64(wrappedConfig.ChainIdRaw)
return nil
}
}
Expand All @@ -149,8 +166,8 @@ func NewFeeConfig(fns ...ConfigOption) (*FeeConfig, error) {
return c, errors.New("verifier_token is required")
}

if c.ChainId == nil {
return c, errors.New("chain_id is required")
if c.ChainId == nil || c.ChainId.Uint64() == 0 {
return c, errors.New("chain_id is required and must not be 0")
}

if c.EthProvider == "" {
Expand Down
24 changes: 21 additions & 3 deletions plugin/fees/fees.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,26 @@ func (fp *FeePlugin) executeFeeLoading(ctx context.Context, feePolicy vtypes.Plu
}

// If no draft run is found, create a new one and add the fee to it
dbTx, err := fp.db.Pool().Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}

var txErr error
defer func() {
if txErr != nil {
if err := dbTx.Rollback(ctx); err != nil {
fp.logger.WithError(err).Error("Failed to rollback transaction")
}
} else {
if err := dbTx.Commit(ctx); err != nil {
fp.logger.WithError(err).Error("Failed to commit transaction")
}
}
}()
if run == nil {
run, err = fp.db.CreateFeeRun(ctx, feePolicy.ID, types.FeeRunStateDraft, fee)
if err != nil {
run, txErr = fp.db.CreateFeeRun(ctx, dbTx, feePolicy.ID, types.FeeRunStateDraft, fee)
if txErr != nil {
return fmt.Errorf("failed to create fee run: %w", err)
}
fp.logger.WithFields(logrus.Fields{
Expand All @@ -216,7 +233,7 @@ func (fp *FeePlugin) executeFeeLoading(ctx context.Context, feePolicy vtypes.Plu

// If a draft run is found, add the fee to it
} else {
if err := fp.db.CreateFee(ctx, run.ID, fee); err != nil {
if txErr = fp.db.CreateFee(ctx, dbTx, run.ID, fee); err != nil {
return fmt.Errorf("failed to create fee: %w", err)
Comment on lines +236 to 237

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix error variable reference.

There's a bug where err is used instead of txErr in the error return statement.

-if txErr = fp.db.CreateFee(ctx, dbTx, run.ID, fee); err != nil {
+if txErr = fp.db.CreateFee(ctx, dbTx, run.ID, fee); txErr != nil {

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In plugin/fees/fees.go around lines 236 to 237, the error return statement
incorrectly references the variable 'err' instead of 'txErr'. Update the return
statement to use 'txErr' to correctly propagate the error from the CreateFee
call.

}
fp.logger.WithFields(logrus.Fields{
Expand All @@ -225,6 +242,7 @@ func (fp *FeePlugin) executeFeeLoading(ctx context.Context, feePolicy vtypes.Plu
"runId": run.ID,
}).Info("Fee added to fee run")
}

}
}

Expand Down
121 changes: 90 additions & 31 deletions plugin/fees/helper.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package fees

import (
"context"
"encoding/hex"
"fmt"
"math/big"
Expand All @@ -11,8 +12,13 @@ import (
"github.com/ethereum/go-ethereum/common/hexutil"
etypes "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/rlp"
"github.com/google/uuid"
"github.com/vultisig/plugin/common"
reth "github.com/vultisig/recipes/ethereum"
"github.com/vultisig/recipes/sdk/evm"
"github.com/vultisig/verifier/address"
vcommon "github.com/vultisig/verifier/common"
vtypes "github.com/vultisig/verifier/types"
)

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

type erc20tx struct {
type erc20Data struct {
to ecommon.Address `json:"to"`
amount *big.Int `json:"amount"`
token ecommon.Address `json:"token"`
}

func decodeTx(rawHex string) (*erc20tx, error) {
type unsignedDynamicFeeTx struct {
ChainID *big.Int
Nonce uint64
GasTipCap *big.Int
GasFeeCap *big.Int
Gas uint64
To *ecommon.Address
Value *big.Int
Data []byte
AccessList etypes.AccessList
}
type unsignedDynamicFeeTx struct {
ChainID *big.Int
Nonce uint64
GasTipCap *big.Int
GasFeeCap *big.Int
Gas uint64
To *ecommon.Address
Value *big.Int
Data []byte
AccessList etypes.AccessList
}

func decodeUnsignedTx(rawHex string) (*unsignedDynamicFeeTx, error) {
rawHex = strings.TrimPrefix(rawHex, "0x")

rawBytes, err := hex.DecodeString(rawHex)
if err != nil {
return nil, fmt.Errorf("hex decode failed: %w", err)
}

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

// Decode RLP (strip type byte)
tx := new(unsignedDynamicFeeTx)
err = rlp.DecodeBytes(rawBytes[1:], tx)
if err != nil {
if err := rlp.DecodeBytes(rawBytes[1:], tx); err != nil {
return nil, fmt.Errorf("rlp decode failed: %w", err)
}

// Parse the ERC20 transfer ABI
return tx, nil
}

// pass in a types.Transaction or a unsignedDynamicFeeTx
func parseErc20Tx[T any](transaction T) (*erc20Data, error) {
// Decode ERC20 transfer ABI
const transferABI = `[{"name":"transfer","type":"function","inputs":[{"name":"to","type":"address"},{"name":"value","type":"uint256"}],"outputs":[{"name":"","type":"bool"}]}]`
parsedABI, err := abi.JSON(strings.NewReader(transferABI))
if err != nil {
return nil, fmt.Errorf("failed to parse ABI")
parsedABI, _ := abi.JSON(strings.NewReader(transferABI))

var data []byte
var to *ecommon.Address

switch tx := any(transaction).(type) {
case *etypes.Transaction:
data = tx.Data()
to = tx.To()
case *unsignedDynamicFeeTx:
data = tx.Data
to = tx.To
default:
return nil, fmt.Errorf("unsupported transaction struct type")
}

// Get the method by selector
method, err := parsedABI.MethodById(tx.Data[:4])
method, err := parsedABI.MethodById(data[:4])
if err != nil {
return nil, fmt.Errorf("unknown method ID")
}

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

recipient, ok := args["to"].(ecommon.Address)
if !ok {
return nil, fmt.Errorf("invalid recipient address in tx data")
return nil, fmt.Errorf("invalid recipient type")
}

amount, ok := args["value"].(*big.Int)
if !ok {
return nil, fmt.Errorf("invalid amount in tx data")
return nil, fmt.Errorf("invalid amount type")
}

return &erc20tx{
return &erc20Data{
to: recipient,
amount: amount,
token: *tx.To,
token: *to,
}, nil
}

Expand All @@ -113,3 +130,45 @@ func hexutilDecode(hexStr string) ([]byte, error) {
}
return hexutil.Decode(hexStr)
}

func appendSignature(inTx evm.UnsignedTx, r, s, v []byte, chainID *big.Int) (*etypes.Transaction, error) {
var sig []byte
sig = append(sig, r...)
sig = append(sig, s...)
sig = append(sig, v...)

inTxDecoded, err := reth.DecodeUnsignedPayload(inTx)
if err != nil {
return nil, fmt.Errorf("reth.DecodeUnsignedPayload: %w", err)
}

outTx, err := etypes.NewTx(inTxDecoded).WithSignature(etypes.LatestSignerForChainID(chainID), sig)
if err != nil {
return nil, fmt.Errorf("types.NewTx.WithSignature: %w", err)
}

return outTx, nil
}

func (fp *FeePlugin) getEthAddressFromFeePolicy(policy vtypes.PluginPolicy) (string, error) {
vault, err := common.GetVaultFromPolicy(fp.vaultStorage, policy, fp.encryptionSecret)
if err != nil {
return "", fmt.Errorf("failed to get vault from policy: %v", err)
}

// Get the ethereum derived addresses from the vaults master public key
fromAddress, _, _, err := address.GetAddress(vault.PublicKeyEcdsa, vault.HexChainCode, vcommon.Ethereum)
if err != nil {
return "", fmt.Errorf("failed to get eth address: %v", err)
}

return fromAddress, nil
}

func (fp *FeePlugin) getEthAddressFromFeePolicyId(policyId uuid.UUID) (string, error) {
policy, err := fp.db.GetPluginPolicy(context.Background(), policyId)
if err != nil {
return "", fmt.Errorf("failed to get policy: %v", err)
}
return fp.getEthAddressFromFeePolicy(*policy)
}
Loading