diff --git a/api/plugin.go b/api/plugin.go index b8dc0e9..7a20fca 100644 --- a/api/plugin.go +++ b/api/plugin.go @@ -180,7 +180,7 @@ func (s *Server) CreatePluginPolicy(c echo.Context) error { // We re-init plugin as verification server doesn't have plugin defined if err := s.plugin.ValidatePluginPolicy(policy); err != nil { - s.logger.WithError(err).Error("Failed to validate plugin policy") + s.logger.WithError(err).Error("failed to validate plugin policy") return c.JSON(http.StatusBadRequest, NewErrorResponse("failed to validate policy")) } diff --git a/plugin/common/common.go b/plugin/common/common.go deleted file mode 100644 index f8a9a81..0000000 --- a/plugin/common/common.go +++ /dev/null @@ -1,242 +0,0 @@ -package common - -import ( - "context" - "fmt" - "math/big" - "strconv" - "strings" - - "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi" - gcommon "github.com/ethereum/go-ethereum/common" - gtypes "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/ethclient" - "github.com/ethereum/go-ethereum/rlp" - reth "github.com/vultisig/recipes/ethereum" - vcommon "github.com/vultisig/verifier/common" - "golang.org/x/sync/errgroup" -) - -var ethereumMainnetEvmChainID *big.Int = big.NewInt(1) // 1 is the chain id for Ethereum mainnet -var ethereumZeroAddress gcommon.Address = gcommon.HexToAddress("0x0000000000000000000000000000000000000000") - -// DEFINE COMMON PLUGIN FUNCTIONS HERE (eg creating an unsigned erc20 transaction) - -// Creates an unsigned ERC20 transaction -func GenUnsignedTx( - ctx context.Context, - chain vcommon.Chain, - senderAddress, tokenID, amount, to string, - rpcClient *ethclient.Client, - nonce uint64, -) ([]byte, error) { - switch chain { - case vcommon.Ethereum: - tx, err := EvmMakeUnsignedTransfer( - ctx, - ethereumMainnetEvmChainID, - senderAddress, - tokenID, - amount, - to, - rpcClient, - nonce, - ) - if err != nil { - return nil, fmt.Errorf("p.evmMakeUnsignedTransfer: %v", err) - } - return tx, nil - default: - return nil, fmt.Errorf("unsupported chain: %s", chain) - } -} - -func EvmMakeUnsignedTransfer( - ctx context.Context, - evmChainID *big.Int, - senderAddress, tokenIDStr, amountStr, toStr string, - rpcClient *ethclient.Client, - nonce uint64, -) ([]byte, error) { - amount, ok := new(big.Int).SetString(amountStr, 10) - if !ok { - return nil, fmt.Errorf("new(big.Int).SetString: %s", amountStr) - } - - to := gcommon.HexToAddress(toStr) - tokenID := gcommon.HexToAddress(tokenIDStr) - - var ( - value *big.Int - data []byte - ) - if tokenID == ethereumZeroAddress { - value = amount - data = nil - } else { - parsedABI, err := abi.JSON(strings.NewReader(Erc20ABI)) - if err != nil { - return nil, fmt.Errorf("abi.JSON(strings.NewReader(erc20ABI)): %v", err) - } - - d, err := parsedABI.Pack("transfer", to, amount) - if err != nil { - return nil, fmt.Errorf("parsedABI.Pack: %v", err) - } - value = big.NewInt(0) - data = d - } - - senderAddressHex := gcommon.HexToAddress(senderAddress) - - nonce, gasLimit, gasTipCap, maxFeePerGas, accessList, err := EvmEstimateTx( - ctx, - senderAddressHex, - tokenID, - value, - data, - rpcClient, - nonce, - ) - if err != nil { - return nil, fmt.Errorf("p.evmEstimateTx: %v", err) - } - - bytes, err := EvmEncodeUnsignedDynamicFeeTx( - evmChainID, - nonce, - tokenID, - gasTipCap, - maxFeePerGas, - gasLimit, - value, - data, - accessList, - ) - if err != nil { - return nil, fmt.Errorf("evmEncodeUnsignedDynamicFeeTx: %v", err) - } - return bytes, nil -} - -func EvmEstimateTx( - ctx context.Context, - from, to gcommon.Address, - value *big.Int, - data []byte, - rpcClient *ethclient.Client, - nonce uint64, -) (uint64, uint64, *big.Int, *big.Int, gtypes.AccessList, error) { - var eg errgroup.Group - var gasLimit uint64 - eg.Go(func() error { - r, e := rpcClient.EstimateGas(ctx, ethereum.CallMsg{ - From: from, - To: &to, - Data: data, - Value: value, - }) - if e != nil { - return fmt.Errorf("p.rpcClient.EstimateGas: %v", e) - } - gasLimit = r - return nil - }) - - var gasTipCap *big.Int - eg.Go(func() error { - r, e := rpcClient.SuggestGasTipCap(ctx) - if e != nil { - return fmt.Errorf("p.rpcClient.SuggestGasTipCap: %v", e) - } - gasTipCap = r - return nil - }) - - var baseFee *big.Int - eg.Go(func() error { - feeHistory, e := rpcClient.FeeHistory(ctx, 1, nil, nil) - if e != nil { - return fmt.Errorf("p.rpcClient.FeeHistory: %v", e) - } - if len(feeHistory.BaseFee) == 0 { - return fmt.Errorf("feeHistory.BaseFee is empty") - } - baseFee = feeHistory.BaseFee[0] - return nil - }) - - err := eg.Wait() - if err != nil { - return 0, 0, nil, nil, nil, fmt.Errorf("eg.Wait: %v", err) - } - - maxFeePerGas := new(big.Int).Add(gasTipCap, baseFee) - - type createAccessListArgs struct { - From string `json:"from,omitempty"` - To string `json:"to,omitempty"` - Gas string `json:"gas,omitempty"` - GasPrice string `json:"gasPrice,omitempty"` - MaxPriorityFeePerGas string `json:"maxPriorityFeePerGas,omitempty"` - MaxFeePerGas string `json:"maxFeePerGas,omitempty"` - Value string `json:"value,omitempty"` - Data string `json:"data,omitempty"` - } - createAccessListRes := struct { - AccessList gtypes.AccessList `json:"accessList"` - GasUsed string `json:"gasUsed"` - }{} - err = rpcClient.Client().CallContext( - ctx, - &createAccessListRes, - "eth_createAccessList", - []interface{}{ - createAccessListArgs{ - From: from.Hex(), - To: to.Hex(), - Gas: "0x" + strconv.FormatUint(gasLimit, 16), - MaxPriorityFeePerGas: "0x" + gcommon.Bytes2Hex(gasTipCap.Bytes()), - MaxFeePerGas: "0x" + gcommon.Bytes2Hex(maxFeePerGas.Bytes()), - Value: "0x" + gcommon.Bytes2Hex(value.Bytes()), - Data: "0x" + gcommon.Bytes2Hex(data), - }, - "latest", - }, - ) - if err != nil { - return 0, 0, nil, nil, nil, fmt.Errorf("p.rpcClient.Client().CallContext: %v", err) - } - - return nonce, gasLimit, gasTipCap, maxFeePerGas, createAccessListRes.AccessList, nil -} - -func EvmEncodeUnsignedDynamicFeeTx( - evmChainID *big.Int, - nonce uint64, - to gcommon.Address, - maxPriorityFeePerGas, maxFeePerGas *big.Int, - gas uint64, - value *big.Int, - data []byte, - accessList gtypes.AccessList, -) ([]byte, error) { - bytes, err := rlp.EncodeToBytes(reth.DynamicFeeTxWithoutSignature{ - ChainID: evmChainID, - Nonce: nonce, - GasTipCap: maxPriorityFeePerGas, - GasFeeCap: maxFeePerGas, - Gas: gas, - To: &to, - Value: value, - Data: data, - AccessList: accessList, - }) - if err != nil { - return nil, fmt.Errorf("rlp.EncodeToBytes: %v", err) - } - - res := append([]byte{gtypes.DynamicFeeTxType}, bytes...) - return res, nil -} diff --git a/plugin/common/nonce.go b/plugin/common/nonce.go deleted file mode 100644 index 8e7a52b..0000000 --- a/plugin/common/nonce.go +++ /dev/null @@ -1,35 +0,0 @@ -package common - -import ( - "context" - "fmt" - "sync" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/ethclient" -) - -// Copy of the nonce manager from payroll but with cache removed. Can be added later. - -type NonceManager struct { - rpcClient *ethclient.Client - mu sync.Mutex -} - -func NewNonceManager(rpcClient *ethclient.Client) *NonceManager { - return &NonceManager{ - rpcClient: rpcClient, - } -} - -func (n *NonceManager) GetNextNonce(address string) (uint64, error) { - n.mu.Lock() - defer n.mu.Unlock() - - nonce, err := n.rpcClient.PendingNonceAt(context.Background(), common.HexToAddress(address)) - if err != nil { - return 0, fmt.Errorf("failed to get nonce from network: %w", err) - } - - return nonce, nil -} diff --git a/plugin/fees/fees.go b/plugin/fees/fees.go index dc581f4..bce5584 100644 --- a/plugin/fees/fees.go +++ b/plugin/fees/fees.go @@ -9,7 +9,6 @@ import ( "github.com/google/uuid" "github.com/hibiken/asynq" "github.com/sirupsen/logrus" - "github.com/vultisig/mobile-tss-lib/tss" vcommon "github.com/vultisig/verifier/common" "github.com/vultisig/verifier/plugin" "github.com/vultisig/verifier/tx_indexer" @@ -20,8 +19,8 @@ import ( "github.com/vultisig/plugin/internal/types" "github.com/vultisig/plugin/internal/verifierapi" - plugincommon "github.com/vultisig/plugin/plugin/common" "github.com/vultisig/plugin/storage" + "github.com/vultisig/recipes/sdk/evm" ) /* @@ -36,12 +35,11 @@ type FeePlugin struct { vaultService *vault.ManagementService vaultStorage *vault.BlockStorageImp db storage.DatabaseStorage - rpcClient *ethclient.Client + eth *evm.SDK logger logrus.FieldLogger verifierApi *verifierapi.VerifierApi config *FeeConfig txIndexerService *tx_indexer.Service - nonceManager *plugincommon.NonceManager asynqInspector *asynq.Inspector asynqClient *asynq.Client encryptionSecret string @@ -66,6 +64,12 @@ func NewFeePlugin(db storage.DatabaseStorage, return nil, err } + // Initialize the Ethereum SDK for transaction broadcasting + ethEvmChainID, err := vcommon.Ethereum.EvmID() + if err != nil { + return nil, fmt.Errorf("vcommon.Ethereum.EvmID: %w", err) + } + if _, ok := logger.(*logrus.Logger); !ok { return nil, fmt.Errorf("logger must be *logrus.Logger, got %T", logger) } @@ -85,7 +89,7 @@ func NewFeePlugin(db storage.DatabaseStorage, return &FeePlugin{ db: db, - rpcClient: rpcClient, + eth: evm.NewSDK(ethEvmChainID, rpcClient, rpcClient.Client()), logger: logger.WithField("plugin", "fees"), config: feeConfig, verifierApi: verifierApi, @@ -93,20 +97,12 @@ func NewFeePlugin(db storage.DatabaseStorage, txIndexerService: txIndexerService, asynqInspector: inspector, asynqClient: client, - nonceManager: plugincommon.NewNonceManager(rpcClient), encryptionSecret: encryptionSecret, }, nil } -func (fp *FeePlugin) ValidateProposedTransactions(policy vtypes.PluginPolicy, txs []vtypes.PluginKeysignRequest) error { - return nil -} -func (fp *FeePlugin) SigningComplete(ctx context.Context, signature tss.KeysignResponse, signRequest vtypes.PluginKeysignRequest, policy vtypes.PluginPolicy) error { - return nil -} - /* -The handler of the asynq job. Fees can be initialized and collected in 3 ways: +The handler of the asynq job. Fees can initialized and collected in 3 ways: - By public key (queries the db for a single fee_policy and then kicks off the fee collection) - By policy id (queries the db for a fee_policy matching that id and then kicks off the fee collection) - All (queries the db for all fee_policies and then kicks off the fee collection for each policy) @@ -257,7 +253,7 @@ func (fp *FeePlugin) executeFeeCollection(ctx context.Context, feePolicy vtypes. for _, keySignRequest := range keySignRequests { req := keySignRequest - if err := fp.initSign(ctx, req, feePolicy); err != nil { + if err := fp.initSign(ctx, req, feePolicy, feeRun.ID); err != nil { return fmt.Errorf("failed to init sign: %w", err) } } diff --git a/plugin/fees/transaction.go b/plugin/fees/transaction.go index 8e87ac7..bec94d6 100644 --- a/plugin/fees/transaction.go +++ b/plugin/fees/transaction.go @@ -6,6 +6,8 @@ import ( "encoding/json" "errors" "fmt" + "math/big" + "strings" "time" "github.com/google/uuid" @@ -15,8 +17,8 @@ import ( "github.com/vultisig/plugin/common" "github.com/vultisig/plugin/internal/scheduler" "github.com/vultisig/plugin/internal/tasks" - plugincommon "github.com/vultisig/plugin/plugin/common" "github.com/vultisig/recipes/chain" + "github.com/vultisig/recipes/engine" reth "github.com/vultisig/recipes/ethereum" rtypes "github.com/vultisig/recipes/types" rutil "github.com/vultisig/recipes/util" @@ -24,6 +26,8 @@ import ( vcommon "github.com/vultisig/verifier/common" "github.com/vultisig/verifier/tx_indexer/pkg/storage" vtypes "github.com/vultisig/verifier/types" + + gcommon "github.com/ethereum/go-ethereum/common" ) func (fp *FeePlugin) ProposeTransactions(policy vtypes.PluginPolicy) ([]vtypes.PluginKeysignRequest, error) { @@ -116,27 +120,16 @@ func (fp *FeePlugin) ProposeTransactions(policy vtypes.PluginPolicy) ([]vtypes.P "chain_id": chain, "token_id": token.Address, }).Info("transaction already proposed, skipping") - return []vtypes.PluginKeysignRequest{}, nil + return nil, nil } - nonce, err := fp.nonceManager.GetNextNonce(ethAddress) + tx, err := fp.eth.MakeAnyTransfer(ctx, + gcommon.HexToAddress(ethAddress), + gcommon.HexToAddress(recipient), + gcommon.HexToAddress(token.Address), + big.NewInt(int64(amount))) if err != nil { - return []vtypes.PluginKeysignRequest{}, fmt.Errorf("plugincommon.nonceManager.GetNextNonce: %w", err) - } - - tx, e := plugincommon.GenUnsignedTx( - ctx, - chain, - ethAddress, - token.Address, - fmt.Sprint(amount), - recipient, - fp.rpcClient, - nonce, - ) - - if e != nil { - return []vtypes.PluginKeysignRequest{}, fmt.Errorf("plugincommon.genUnsignedTx: %w", e) + return nil, fmt.Errorf("failed to generate unsigned transaction: %w", err) } txHex := hex.EncodeToString(tx) @@ -151,7 +144,7 @@ func (fp *FeePlugin) ProposeTransactions(policy vtypes.PluginPolicy) ([]vtypes.P ProposedTxHex: txHex, }) if e != nil { - return []vtypes.PluginKeysignRequest{}, fmt.Errorf("fp.tx_indexer.CreateTx: %w", e) + return nil, fmt.Errorf("error creating tx indexed transaction: %w", e) } // Create signing request @@ -199,7 +192,7 @@ func (fp *FeePlugin) IsAlreadyProposed( interval, ) if err != nil { - return false, fmt.Errorf("scheduler.NewIntervalSchedule: %w", err) + return false, fmt.Errorf("failed to create interval schedule: %w", err) } fromTime, toTime := sched.ToRangeFrom(time.Now()) @@ -220,17 +213,18 @@ func (fp *FeePlugin) IsAlreadyProposed( if errors.Is(err, storage.ErrNoTx) { return false, nil } - return false, fmt.Errorf("fp.txIndexerService.GetTxInTimeRange: %w", err) + return false, fmt.Errorf("failed to get tx in time range: %w", err) } func (fp *FeePlugin) initSign( ctx context.Context, req vtypes.PluginKeysignRequest, pluginPolicy vtypes.PluginPolicy, + runId uuid.UUID, ) error { buf, e := json.Marshal(req) if e != nil { - return fmt.Errorf("json.Marshal: %w", e) + return fmt.Errorf("failed to marshal key sign request: %w", e) } task, e := fp.asynqClient.Enqueue( @@ -241,7 +235,21 @@ func (fp *FeePlugin) initSign( asynq.Queue(tasks.QUEUE_NAME), ) if e != nil { - return fmt.Errorf("fp.client.Enqueue: %w", e) + return fmt.Errorf("failed to enqueue key sign task: %w", e) + } + + if runId != uuid.Nil { + if len(req.Messages) != 1 { + return fmt.Errorf("multiple messages in key sign request, expected 1") + } + txId, err := uuid.Parse(req.Messages[0].TxIndexerID) + if err != nil { + return fmt.Errorf("failed to parse tx indexer id: %w", err) + } + err = fp.db.SetFeeRunSent(ctx, runId, txId) + if err != nil { + return fmt.Errorf("failed to update fee run: %w", err) + } } for { @@ -251,7 +259,7 @@ func (fp *FeePlugin) initSign( case <-time.After(3 * time.Second): taskInfo, er := fp.asynqInspector.GetTaskInfo(tasks.QUEUE_NAME, task.ID) if er != nil { - return fmt.Errorf("fp.inspector.GetTaskInfo: %w", er) + return fmt.Errorf("failed to get task info: %w", er) } if taskInfo.State != asynq.TaskStateCompleted { continue @@ -264,7 +272,7 @@ func (fp *FeePlugin) initSign( var res map[string]tss.KeysignResponse er = json.Unmarshal(taskInfo.Result, &res) if er != nil { - return fmt.Errorf("json.Unmarshal(taskInfo.Result, &res): %w", er) + return fmt.Errorf("failed to unmarshal task result: %w", er) } var sig tss.KeysignResponse @@ -274,7 +282,7 @@ func (fp *FeePlugin) initSign( er = fp.SigningComplete(ctx, sig, req, pluginPolicy) if er != nil { - return fmt.Errorf("fp.SigningComplete: %w", er) + return fmt.Errorf("failed to sign and broadcast transaction: %w", er) } fp.logger.WithField("public_key", req.PublicKey). @@ -283,3 +291,68 @@ func (fp *FeePlugin) initSign( } } } + +func (fp *FeePlugin) ValidateProposedTransactions(policy vtypes.PluginPolicy, txs []vtypes.PluginKeysignRequest) error { + // First validate the plugin policy itself + err := fp.ValidatePluginPolicy(policy) + if err != nil { + return fmt.Errorf("failed to validate plugin policy: %v", err) + } + + // Get the recipe from the policy for transaction validation + recipe, err := policy.GetRecipe() + if err != nil { + return fmt.Errorf("failed to get recipe from policy: %v", err) + } + + // Create a recipe engine for evaluating transactions + eng := engine.NewEngine() + + // Validate each proposed transaction + for _, tx := range txs { + for _, keysignMessage := range tx.Messages { + // Get the chain for the transaction + messageChain, err := chain.GetChain(strings.ToLower(keysignMessage.Chain.String())) + if err != nil { + return fmt.Errorf("failed to get chain: %w", err) + } + + // Parse the transaction to validate its structure + decodedTx, err := messageChain.ParseTransaction(keysignMessage.Message) + if err != nil { + return fmt.Errorf("failed to parse transaction: %w", err) + } + + // Evaluate if the transaction is allowed by the policy + transactionAllowed, _, err := eng.Evaluate(recipe, messageChain, decodedTx) + if err != nil { + return fmt.Errorf("failed to evaluate transaction: %w", err) + } + + if !transactionAllowed { + return fmt.Errorf("transaction %s on %s not allowed by policy", keysignMessage.Hash, keysignMessage.Chain) + } + } + } + + return nil +} + +func (fp *FeePlugin) SigningComplete(ctx context.Context, signature tss.KeysignResponse, signRequest vtypes.PluginKeysignRequest, policy vtypes.PluginPolicy) error { + // Broadcast the signed transaction to the Ethereum network + tx, err := fp.eth.Send( + ctx, + gcommon.FromHex(signRequest.Transaction), + gcommon.Hex2Bytes(signature.R), + gcommon.Hex2Bytes(signature.S), + gcommon.Hex2Bytes(signature.RecoveryID), + ) + if err != nil { + fp.logger.WithError(err).WithField("tx_hex", signRequest.Transaction).Error("fp.eth.Send") + return fmt.Errorf("failed to send transaction: %w", err) + } + + // Log successful transaction broadcast + fp.logger.WithField("hash", tx.Hash().Hex()).Info("fee collection transaction successfully broadcasted") + return nil +} diff --git a/storage/db.go b/storage/db.go index 501d894..8259256 100644 --- a/storage/db.go +++ b/storage/db.go @@ -31,6 +31,7 @@ type DatabaseStorage interface { GetTriggerStatus(ctx context.Context, policyID uuid.UUID) (types.TimeTriggerStatus, error) CreateFeeRun(ctx context.Context, policyId uuid.UUID, state types.FeeRunState, fees []verifierapi.FeeDto) (*types.FeeRun, error) + SetFeeRunSent(ctx context.Context, runId uuid.UUID, txId uuid.UUID) error Pool() *pgxpool.Pool } diff --git a/storage/postgres/fees.go b/storage/postgres/fees.go index 69da6ad..1198c79 100644 --- a/storage/postgres/fees.go +++ b/storage/postgres/fees.go @@ -55,3 +55,11 @@ func (p *PostgresBackend) CreateFeeRun(ctx context.Context, policyId uuid.UUID, return &run, nil } + +func (p *PostgresBackend) SetFeeRunSent(ctx context.Context, runId uuid.UUID, txId uuid.UUID) error { + _, err := p.pool.Exec(ctx, `update fee_run set status = $1, tx_id = $2 where id = $3`, types.FeeRunStateSent, txId, runId) + if err != nil { + return fmt.Errorf("failed to update fee run: %w", err) + } + return nil +}