Skip to content
This repository was archived by the owner on Feb 8, 2026. It is now read-only.
Merged
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
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,10 @@ require (
github.com/redis/go-redis/v9 v9.8.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0
github.com/vultisig/commondata v0.0.0-20250710214228-61d9ed8f7778
github.com/vultisig/mobile-tss-lib v0.0.0-20250316003201-2e7e570a4a74
github.com/vultisig/recipes v0.0.0-20250729120802-9b1d07f8262a
github.com/vultisig/verifier v0.0.0-20250723150319-a51c59a884bf
github.com/vultisig/verifier v0.0.0-20250728211124-309f39dbdb80
github.com/vultisig/vultiserver v0.0.0-20250715212748-4b23f9849e4b
golang.org/x/sync v0.14.0
google.golang.org/protobuf v1.36.6
Expand Down Expand Up @@ -137,6 +136,7 @@ require (
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect
github.com/spf13/cobra v1.8.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/supranational/blst v0.3.14 // indirect
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect
github.com/tendermint/go-amino v0.16.0 // indirect
Expand Down Expand Up @@ -177,7 +177,7 @@ require (
github.com/otiai10/primes v0.4.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/robfig/cron/v3 v3.0.1
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
Expand Down
14 changes: 2 additions & 12 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -757,20 +757,10 @@ github.com/vultisig/go-wrappers v0.0.0-20250403041248-86911e8aa33f h1:124Xlloih1
github.com/vultisig/go-wrappers v0.0.0-20250403041248-86911e8aa33f/go.mod h1:UfGCxUQW08kiwxyNBiHwXe+ePPuBmHVVS+BS51aU/Jg=
github.com/vultisig/mobile-tss-lib v0.0.0-20250316003201-2e7e570a4a74 h1:goqwk4nQ/NEVIb3OPP9SUx7/u9ZfsUIcd5fIN/e4DVU=
github.com/vultisig/mobile-tss-lib v0.0.0-20250316003201-2e7e570a4a74/go.mod h1:nOykk4nOy1L3yXtLSlYvVsgizBnCQ3tR2N5uwGPdvaM=
github.com/vultisig/recipes v0.0.0-20250723142134-153c7f486070 h1:AcE6x2dxkl/4P/TxaMJ70pa2BcZ3+FJWnVmlPVAq4Dk=
github.com/vultisig/recipes v0.0.0-20250723142134-153c7f486070/go.mod h1:30NOW5y2BnMCmEYFeVls3NhxkbMvuv7BNyRQfoFhGIM=
github.com/vultisig/recipes v0.0.0-20250724111913-675e7fdcbc24 h1:NKkcuAtSSQfaYTQ0Fo96mnTsTDAoTh+XrbrUHLEliNY=
github.com/vultisig/recipes v0.0.0-20250724111913-675e7fdcbc24/go.mod h1:Ot3lrUnnSw67Hep+MelclVPgNLDxJP01Ezixw/1RYRE=
github.com/vultisig/recipes v0.0.0-20250724145933-37c12287503c h1:PFaQfDNMSBymgwu+6Jr9+wYF/tFpv3jk97i8VE2+bSY=
github.com/vultisig/recipes v0.0.0-20250724145933-37c12287503c/go.mod h1:Ot3lrUnnSw67Hep+MelclVPgNLDxJP01Ezixw/1RYRE=
github.com/vultisig/recipes v0.0.0-20250724150617-ab960a120ce8 h1:aMVowo/fdQGizJf7lFDzh07t7GmKvLyddVOWeFm7dkw=
github.com/vultisig/recipes v0.0.0-20250724150617-ab960a120ce8/go.mod h1:Ot3lrUnnSw67Hep+MelclVPgNLDxJP01Ezixw/1RYRE=
github.com/vultisig/recipes v0.0.0-20250724151024-afb75caf8884 h1:ObxhyUAEQYfmuqH2mnvw/fidEmaDPIEbxdp3oIpM9WY=
github.com/vultisig/recipes v0.0.0-20250724151024-afb75caf8884/go.mod h1:Ot3lrUnnSw67Hep+MelclVPgNLDxJP01Ezixw/1RYRE=
github.com/vultisig/recipes v0.0.0-20250729120802-9b1d07f8262a h1:KoAwytLj092KNgWHh0a5tcupsSm5HtentT0yUbDGWFQ=
github.com/vultisig/recipes v0.0.0-20250729120802-9b1d07f8262a/go.mod h1:Ot3lrUnnSw67Hep+MelclVPgNLDxJP01Ezixw/1RYRE=
github.com/vultisig/verifier v0.0.0-20250723150319-a51c59a884bf h1:3VhxFQR0oJEieaajNDvKlOGBDyTBbSMJSxow3zwUlmw=
github.com/vultisig/verifier v0.0.0-20250723150319-a51c59a884bf/go.mod h1:9f3yGSZWKZeXn30mU+/brufkbkLoGlFmNLKOpRbQgeI=
github.com/vultisig/verifier v0.0.0-20250728211124-309f39dbdb80 h1:aOgViLVMBV1E8VicCh2g9pky0I+NXLlWNvzDm5c1HBk=
github.com/vultisig/verifier v0.0.0-20250728211124-309f39dbdb80/go.mod h1:eK+KyWZSgiB/gzDJjsDKx+ry7gtJadplzpMNF4gvkKk=
github.com/vultisig/vultiserver v0.0.0-20250715212748-4b23f9849e4b h1:Ed2DOWo8fA0KG6e36rzUmGpxcOQjmWTbxWyvUbI5by8=
github.com/vultisig/vultiserver v0.0.0-20250715212748-4b23f9849e4b/go.mod h1:HwP2IgW6Mcu/gX8paFuKvfibrGE9UmPgkOFTub6dskM=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
Expand Down
18 changes: 16 additions & 2 deletions plugin/fees/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ func (fp *FeePlugin) ValidatePluginPolicy(policyDoc vtypes.PluginPolicy) error {
}

func (fp *FeePlugin) GetRecipeSpecification() (*rtypes.RecipeSchema, error) {

cfg, err := plugin.RecipeConfiguration(map[string]any{})
if err != nil {
return nil, fmt.Errorf("failed to build pb recipe config: %w", err)
}

return &rtypes.RecipeSchema{
Version: 1, // Schema version
ScheduleVersion: 1, // Schedule specification version
Expand All @@ -38,13 +44,13 @@ func (fp *FeePlugin) GetRecipeSpecification() (*rtypes.RecipeSchema, error) {
ChainId: "ethereum",
ProtocolId: "usdc",
FunctionId: "transfer",
Full: "ethereum.usdc.transfer",
Full: "ethereum.erc20.transfer",
},
ParameterCapabilities: []*rtypes.ParameterConstraintCapability{
{
ParameterName: "recipient",
SupportedTypes: []rtypes.ConstraintType{
rtypes.ConstraintType_CONSTRAINT_TYPE_FIXED,
rtypes.ConstraintType_CONSTRAINT_TYPE_MAGIC_CONSTANT,
},
Required: true,
},
Expand All @@ -55,6 +61,13 @@ func (fp *FeePlugin) GetRecipeSpecification() (*rtypes.RecipeSchema, error) {
},
Required: true,
},
{
ParameterName: "token",
SupportedTypes: []rtypes.ConstraintType{
rtypes.ConstraintType_CONSTRAINT_TYPE_FIXED,
},
Required: true,
},
},
Required: true,
},
Expand All @@ -63,5 +76,6 @@ func (fp *FeePlugin) GetRecipeSpecification() (*rtypes.RecipeSchema, error) {
MinVultisigVersion: 1,
SupportedChains: []string{"ethereum"},
},
Configuration: cfg,
}, nil
}
63 changes: 35 additions & 28 deletions plugin/fees/transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/base64"
"fmt"
"math/big"
"strconv"
"strings"

"github.com/google/uuid"
Expand All @@ -14,10 +15,11 @@ import (
"github.com/vultisig/recipes/chain"
"github.com/vultisig/recipes/engine"
reth "github.com/vultisig/recipes/ethereum"
resolver "github.com/vultisig/recipes/resolver"

rtypes "github.com/vultisig/recipes/types"
"github.com/vultisig/verifier/address"
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"
Expand Down Expand Up @@ -55,30 +57,50 @@ func (fp *FeePlugin) ProposeTransactions(policy vtypes.PluginPolicy) ([]vtypes.P
return nil, fmt.Errorf("failed to get recipe from policy: %v", err)
}

chain := vcommon.Ethereum
txs := []vtypes.PluginKeysignRequest{}

var magicConstantRecipientValue rtypes.MagicConstant = rtypes.MagicConstant_UNSPECIFIED
var token string
Comment thread
garry-sharp marked this conversation as resolved.
// This should only return one rule, but in case there are more/fewer rules, we'll loop through them all and error if it's the case.
for _, rule := range recipe.Rules {

// This section of code goes through the rules in the fee policy. It looks for the recipient of the fee collection policy and extracts it. If other data is found throws an error as they're unsupported rules.
var recipient string // The address specified in the fee policy.
switch rule.Resource {
case "ethereum.usdc.transfer":
case "ethereum.erc20.transfer":
for _, constraint := range rule.ParameterConstraints {
if constraint.ParameterName == "recipient" {
if constraint.Constraint.Type != rtypes.ConstraintType_CONSTRAINT_TYPE_MAGIC_CONSTANT {
return nil, fmt.Errorf("recipient constraint is not a magic constant")
}
iv, err := strconv.ParseInt(constraint.Constraint.GetFixedValue(), 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse fixed value: %v", err)
}
magicConstantRecipientValue = rtypes.MagicConstant(iv)
}
Comment thread
garry-sharp marked this conversation as resolved.
if constraint.ParameterName == "token" {
if constraint.Constraint.Type != rtypes.ConstraintType_CONSTRAINT_TYPE_FIXED {
return nil, fmt.Errorf("recipient constraint is not a fixed value")
return nil, fmt.Errorf("token constraint is not a fixed value")
}
token = constraint.Constraint.GetFixedValue()
}
fixedValue := constraint.Constraint.GetValue().(*rtypes.Constraint_FixedValue)
recipient = fixedValue.FixedValue
}
default:
return nil, fmt.Errorf("unsupported rule: %v", rule.Id)
}
if recipient == "" {
return nil, fmt.Errorf("recipient is not set in policy")

if magicConstantRecipientValue != rtypes.MagicConstant_VULTISIG_TREASURY {
return nil, fmt.Errorf("recipient constraint is not a treasury magic constant")
}

treasuryResolver := resolver.NewDefaultTreasuryResolver()
recipient, _, err := treasuryResolver.Resolve(magicConstantRecipientValue, "ethereum", "usdc")
Comment thread
garry-sharp marked this conversation as resolved.
if err != nil {
return nil, fmt.Errorf("failed to resolve treasury address: %v", err)
}

if gcommon.HexToAddress(token) != gcommon.HexToAddress(usdc.Address) {
Comment thread
garry-sharp marked this conversation as resolved.
return nil, fmt.Errorf("token address does not match usdc address")
}

// Here we call the verifier api to get a list of fees that have the same public key as the signed policy document.
Expand All @@ -105,28 +127,15 @@ func (fp *FeePlugin) ProposeTransactions(policy vtypes.PluginPolicy) ([]vtypes.P
}
txHashToSign := etypes.LatestSignerForChainID(fp.config.ChainId).Hash(etypes.NewTx(txData))

txToTrack, e := fp.txIndexerService.CreateTx(ctx, storage.CreateTxDto{
PluginID: policy.PluginID,
PolicyID: policy.ID,
ChainID: chain,
TokenID: usdc.Address,
FromPublicKey: policy.PublicKey,
ToPublicKey: recipient,
ProposedTxHex: txHex,
})
if e != nil {
return nil, fmt.Errorf("error creating tx indexed transaction: %w", e)
}

msgHash := sha256.Sum256(txHashToSign.Bytes())

signRequest := vtypes.PluginKeysignRequest{
KeysignRequest: vtypes.KeysignRequest{
PublicKey: policy.PublicKey,
Messages: []vtypes.KeysignMessage{
{
TxIndexerID: txToTrack.ID.String(),
Message: base64.StdEncoding.EncodeToString(txHashToSign.Bytes()),
RawMessage: txHex,
Chain: vcommon.Ethereum,
Hash: base64.StdEncoding.EncodeToString(msgHash[:]),
HashFunction: vtypes.HashFunction_SHA256,
Expand Down Expand Up @@ -157,11 +166,8 @@ func (fp *FeePlugin) initSign(
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)

err := fp.db.SetFeeRunSent(ctx, runId, uuid.Nil) // TODO this will be replaced imminently in an upcoming PR
Comment thread
garry-sharp marked this conversation as resolved.
if err != nil {
return fmt.Errorf("failed to update fee run: %w", err)
}
Expand Down Expand Up @@ -240,6 +246,7 @@ func (fp *FeePlugin) ValidateProposedTransactions(policy vtypes.PluginPolicy, tx

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),
Expand Down
42 changes: 41 additions & 1 deletion storage/postgres/schema/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,39 @@ CREATE TYPE "tx_indexer_status_onchain" AS ENUM (
'FAIL'
);

CREATE FUNCTION "prevent_insert_if_policy_deleted"() RETURNS "trigger"
LANGUAGE "plpgsql"
AS $$
BEGIN
IF NEW.deleted = true THEN
RAISE EXCEPTION 'Cannot insert a deleted policy';
END IF;
RETURN NEW;
END;
$$;

CREATE FUNCTION "prevent_update_if_policy_deleted"() RETURNS "trigger"
LANGUAGE "plpgsql"
AS $$
BEGIN
IF OLD.deleted = true THEN
RAISE EXCEPTION 'Cannot update a deleted policy';
END IF;
RETURN NEW;
END;
$$;

CREATE FUNCTION "set_policy_inactive_on_delete"() RETURNS "trigger"
LANGUAGE "plpgsql"
AS $$
BEGIN
IF NEW.deleted = true THEN
NEW.active := false;
END IF;
RETURN NEW;
END;
$$;

CREATE FUNCTION "update_updated_at_column"() RETURNS "trigger"
LANGUAGE "plpgsql"
AS $$
Expand Down Expand Up @@ -66,7 +99,8 @@ CREATE TABLE "plugin_policies" (
"recipe" "text" NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
"updated_at" timestamp with time zone DEFAULT "now"() NOT NULL
"updated_at" timestamp with time zone DEFAULT "now"() NOT NULL,
"deleted" boolean DEFAULT false NOT NULL
);
Comment on lines 100 to 104

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.

🛠️ Refactor suggestion

updated_at column is never refreshed – add an UPDATE trigger for plugin_policies.

You introduce the updated_at column (Line 102) but, unlike fee_run, there is no BEFORE UPDATE trigger that bumps the timestamp. As a result, updated_at will always equal created_at, defeating the purpose of the column and making audit trails unreliable.

Add a trigger re-using the already defined update_updated_at_column() helper:

+-- Keep the timestamp current on every row modification
+CREATE TRIGGER "update_plugin_policies_updated_at"
+  BEFORE UPDATE ON "plugin_policies"
+  FOR EACH ROW
+  EXECUTE FUNCTION "public"."update_updated_at_column"();

Place it next to the other plugin_policies triggers (Lines 162-166) so it is obvious and executed in the same migration step.

Also applies to: 162-166

🤖 Prompt for AI Agents
In storage/postgres/schema/schema.sql around lines 100 to 104, the updated_at
column is defined but never updated on row modifications, so it remains equal to
created_at. To fix this, add a BEFORE UPDATE trigger on the plugin_policies
table that calls the existing update_updated_at_column() function. Place this
trigger definition near the other plugin_policies triggers around lines 162 to
166 to keep related changes together and ensure updated_at is refreshed
automatically on updates.


CREATE TABLE "scheduler" (
Expand Down Expand Up @@ -125,6 +159,12 @@ CREATE INDEX "idx_tx_indexer_key" ON "tx_indexer" USING "btree" ("chain_id", "pl

CREATE INDEX "idx_tx_indexer_status_onchain_lost" ON "tx_indexer" USING "btree" ("status_onchain", "lost");

CREATE TRIGGER "trg_prevent_insert_if_policy_deleted" BEFORE INSERT ON "plugin_policies" FOR EACH ROW EXECUTE FUNCTION "public"."prevent_insert_if_policy_deleted"();

CREATE TRIGGER "trg_prevent_update_if_policy_deleted" BEFORE UPDATE ON "plugin_policies" FOR EACH ROW WHEN (("old"."deleted" = true)) EXECUTE FUNCTION "public"."prevent_update_if_policy_deleted"();

CREATE TRIGGER "trg_set_policy_inactive_on_delete" BEFORE INSERT OR UPDATE ON "plugin_policies" FOR EACH ROW WHEN (("new"."deleted" = true)) EXECUTE FUNCTION "public"."set_policy_inactive_on_delete"();

CREATE TRIGGER "update_fee_run_updated_at" BEFORE UPDATE ON "fee_run" FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"();

ALTER TABLE ONLY "fee"
Expand Down