This repository was archived by the owner on Feb 8, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 3
payroll: propose and validated transactions based on recipes #100
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package common | ||
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| v1 "github.com/vultisig/commondata/go/vultisig/vault/v1" | ||
| vcommon "github.com/vultisig/verifier/common" | ||
| vtypes "github.com/vultisig/verifier/types" | ||
| vault "github.com/vultisig/verifier/vault" | ||
| ) | ||
|
|
||
| func GetVaultFromPolicy(s vault.Storage, policy vtypes.PluginPolicy, encryptionSecret string) (*v1.Vault, error) { | ||
| vaultFileName := vcommon.GetVaultBackupFilename(policy.PublicKey, policy.PluginID.String()) | ||
| vaultContent, err := s.GetVault(vaultFileName) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to get vault") | ||
| } | ||
|
|
||
| if vaultContent == nil { | ||
| return nil, fmt.Errorf("vault not found") | ||
| } | ||
|
|
||
| return vcommon.DecryptVaultFromBackup(encryptionSecret, vaultContent) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,135 +2,51 @@ package payroll | |||||||||||||||||||
|
|
||||||||||||||||||||
| import ( | ||||||||||||||||||||
| "encoding/base64" | ||||||||||||||||||||
| "encoding/hex" | ||||||||||||||||||||
| "fmt" | ||||||||||||||||||||
| "math/big" | ||||||||||||||||||||
| "strconv" | ||||||||||||||||||||
| "strings" | ||||||||||||||||||||
| "time" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| "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/rlp" | ||||||||||||||||||||
| "github.com/golang/protobuf/proto" | ||||||||||||||||||||
| "github.com/vultisig/recipes/chain" | ||||||||||||||||||||
| "github.com/vultisig/recipes/engine" | ||||||||||||||||||||
| rtypes "github.com/vultisig/recipes/types" | ||||||||||||||||||||
| vtypes "github.com/vultisig/verifier/types" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| type PayrollPolicy struct { | ||||||||||||||||||||
| ChainID []string `json:"chain_id"` | ||||||||||||||||||||
| TokenID []string `json:"token_id"` | ||||||||||||||||||||
| Recipients []PayrollRecipient `json:"recipients"` | ||||||||||||||||||||
| Schedule Schedule `json:"schedule"` | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| type PayrollRecipient struct { | ||||||||||||||||||||
| Address string `json:"address"` | ||||||||||||||||||||
| Amount string `json:"amount"` | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // This is duplicated between DCA and Payroll to avoid a | ||||||||||||||||||||
| // circular top-level dependency on the types package | ||||||||||||||||||||
| type Schedule struct { | ||||||||||||||||||||
| Frequency string `json:"frequency"` | ||||||||||||||||||||
| Interval string `json:"interval"` | ||||||||||||||||||||
| StartTime string `json:"start_time"` | ||||||||||||||||||||
| EndTime string `json:"end_time,omitempty"` | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| type ScheduleTyped struct { | ||||||||||||||||||||
| Frequency rtypes.ScheduleFrequency | ||||||||||||||||||||
| Interval int | ||||||||||||||||||||
| StartTime time.Time | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| func (s Schedule) Typed() (ScheduleTyped, error) { | ||||||||||||||||||||
| frequency, ok := rtypes.ScheduleFrequency_value[s.Frequency] | ||||||||||||||||||||
| if !ok { | ||||||||||||||||||||
| return ScheduleTyped{}, fmt.Errorf("unknown schedule frequency: %s", s.Frequency) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| interval, err := strconv.Atoi(s.Interval) | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return ScheduleTyped{}, fmt.Errorf("strconv.Atoi(%s): %w", s.Interval, err) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| startTime, err := time.Parse(time.RFC3339, s.StartTime) | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return ScheduleTyped{}, fmt.Errorf("time.Parse(%s): %w", s.StartTime, err) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| return ScheduleTyped{ | ||||||||||||||||||||
| Frequency: rtypes.ScheduleFrequency(frequency), | ||||||||||||||||||||
| Interval: interval, | ||||||||||||||||||||
| StartTime: startTime, | ||||||||||||||||||||
| }, nil | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| func (p *PayrollPlugin) ValidateProposedTransactions(policy vtypes.PluginPolicy, txs []vtypes.PluginKeysignRequest) error { | ||||||||||||||||||||
| err := p.ValidatePluginPolicy(policy) | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return fmt.Errorf("failed to validate plugin policy: %v", err) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| parsedABI, err := abi.JSON(strings.NewReader(erc20ABI)) | ||||||||||||||||||||
| recipe, err := policy.GetRecipe() | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return fmt.Errorf("failed to parse ABI: %v", err) | ||||||||||||||||||||
| return fmt.Errorf("failed to get recipe from policy: %v", err) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+23
to
26
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Wrap the original error for better debugging. The error message should include the original error from recipe, err := policy.GetRecipe()
if err != nil {
- return fmt.Errorf("failed to get recipe from policy: %v", err)
+ return fmt.Errorf("failed to get recipe from policy: %w", err)
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
|
|
||||||||||||||||||||
| var payrollPolicy PayrollPolicy | ||||||||||||||||||||
| // TODO: convert the recipes to PayrollPolicy | ||||||||||||||||||||
| for i, tx := range txs { | ||||||||||||||||||||
| var parsedTx *gtypes.Transaction | ||||||||||||||||||||
| txBytes, err := hex.DecodeString(tx.Transaction) | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return fmt.Errorf("failed to decode transaction: %v", err) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| eng := engine.NewEngine() | ||||||||||||||||||||
|
|
||||||||||||||||||||
| err = rlp.DecodeBytes(txBytes, &parsedTx) | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return fmt.Errorf("failed to parse transaction: %v", err) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| txDestination := parsedTx.To() | ||||||||||||||||||||
| if txDestination == nil { | ||||||||||||||||||||
| return fmt.Errorf("transaction destination is nil") | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if strings.ToLower(txDestination.Hex()) != strings.ToLower(payrollPolicy.TokenID[i]) { | ||||||||||||||||||||
| // ERC20 transfer tx 'to' is contract address, and 'recipient' encoded in calldata | ||||||||||||||||||||
| return fmt.Errorf("transaction destination does not match token ID") | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| txData := parsedTx.Data() | ||||||||||||||||||||
| m, err := parsedABI.MethodById(txData[:4]) | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return fmt.Errorf("failed to get method by ID: %v", err) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| v := make(map[string]interface{}) | ||||||||||||||||||||
| if err := m.Inputs.UnpackIntoMap(v, txData[4:]); err != nil { | ||||||||||||||||||||
| return fmt.Errorf("failed to unpack transaction data: %v", err) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| fmt.Printf("Decoded: %+v\n", v) | ||||||||||||||||||||
| for _, tx := range txs { | ||||||||||||||||||||
| for _, keysignMessage := range tx.Messages { | ||||||||||||||||||||
| messageChain, err := chain.GetChain(strings.ToLower(keysignMessage.Chain.String())) | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return fmt.Errorf("failed to get chain: %w", err) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| recipientAddress, ok := v["recipient"].(gcommon.Address) | ||||||||||||||||||||
| if !ok { | ||||||||||||||||||||
| return fmt.Errorf("failed to get recipient address") | ||||||||||||||||||||
| } | ||||||||||||||||||||
| decodedTx, err := messageChain.ParseTransaction(keysignMessage.Message) | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return fmt.Errorf("failed to parse transaction: %w", err) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| var recipientFound bool | ||||||||||||||||||||
| for _, recipient := range payrollPolicy.Recipients { | ||||||||||||||||||||
| if strings.EqualFold(recipientAddress.Hex(), recipient.Address) { | ||||||||||||||||||||
| recipientFound = true | ||||||||||||||||||||
| break | ||||||||||||||||||||
| transactionAllowed, _, err := eng.Evaluate(recipe, messageChain, decodedTx) | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return fmt.Errorf("failed to evaluate transaction: %w", err) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if !recipientFound { | ||||||||||||||||||||
| return fmt.Errorf("recipient not found in policy") | ||||||||||||||||||||
| if !transactionAllowed { | ||||||||||||||||||||
| return fmt.Errorf("transaction %s on %s not allowed by policy", keysignMessage.Hash, keysignMessage.Chain) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
@@ -184,6 +100,7 @@ func (p *PayrollPlugin) validateAmount(pc *rtypes.ParameterConstraint) error { | |||||||||||||||||||
| } | ||||||||||||||||||||
| return nil | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| func (p *PayrollPlugin) validateSchedule(schedule *rtypes.Schedule) error { | ||||||||||||||||||||
| if schedule == nil { | ||||||||||||||||||||
| return fmt.Errorf("schedule is nil") | ||||||||||||||||||||
|
|
@@ -202,6 +119,7 @@ func (p *PayrollPlugin) validateSchedule(schedule *rtypes.Schedule) error { | |||||||||||||||||||
|
|
||||||||||||||||||||
| return nil | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| func (p *PayrollPlugin) checkRule(rule *rtypes.Rule) error { | ||||||||||||||||||||
| if rule.Effect != rtypes.Effect_EFFECT_ALLOW { | ||||||||||||||||||||
| return fmt.Errorf("rule effect must be ALLOW, got: %s", rule.Effect) | ||||||||||||||||||||
|
|
@@ -228,6 +146,7 @@ func (p *PayrollPlugin) checkRule(rule *rtypes.Rule) error { | |||||||||||||||||||
| } | ||||||||||||||||||||
| return nil | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| func (p *PayrollPlugin) ValidatePluginPolicy(policyDoc vtypes.PluginPolicy) error { | ||||||||||||||||||||
| if policyDoc.PluginID != vtypes.PluginVultisigPayroll_0000 { | ||||||||||||||||||||
| return fmt.Errorf("policy does not match plugin type, expected: %s, got: %s", vtypes.PluginVultisigPayroll_0000, policyDoc.PluginID) | ||||||||||||||||||||
|
|
||||||||||||||||||||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Improve error messages to include more context.
The error messages should wrap the original errors and provide more context for better debugging.
Apply this diff to improve error handling:
func GetVaultFromPolicy(s vault.Storage, policy vtypes.PluginPolicy, encryptionSecret string) (*v1.Vault, error) { vaultFileName := vcommon.GetVaultBackupFilename(policy.PublicKey, policy.PluginID.String()) vaultContent, err := s.GetVault(vaultFileName) if err != nil { - return nil, fmt.Errorf("failed to get vault") + return nil, fmt.Errorf("failed to get vault %s: %w", vaultFileName, err) } if vaultContent == nil { - return nil, fmt.Errorf("vault not found") + return nil, fmt.Errorf("vault not found for file %s", vaultFileName) } return vcommon.DecryptVaultFromBackup(encryptionSecret, vaultContent) }🤖 Prompt for AI Agents