Skip to content
Open
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
17 changes: 8 additions & 9 deletions .github/workflows/deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
branches: ['main']

env:
GO_VERSION: '1.23'
GO_VERSION: '1.25'

jobs:
test:
Expand All @@ -23,6 +23,13 @@ jobs:
go-version: ${{ env.GO_VERSION }}
cache: true

- name: Check formatting
run: |
if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then
echo "Code is not formatted properly:"
gofmt -s -l .
exit 1
fi

- name: Install Clang
run: sudo apt-get install -y clang
Expand All @@ -40,14 +47,6 @@ jobs:
go test ./...
# - name: Run vet
# run: go vet ./...

- name: Check formatting
run: |
if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then
echo "Code is not formatted properly:"
gofmt -s -l .
exit 1
fi
build:
needs: test
runs-on: ubuntu-22.04
Expand Down
65 changes: 63 additions & 2 deletions internal/api/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import (
v1 "github.com/vultisig/commondata/go/vultisig/vault/v1"
"github.com/vultisig/recipes/engine"
rtypes "github.com/vultisig/recipes/types"
"golang.org/x/sync/errgroup"
"google.golang.org/protobuf/proto"

"github.com/vultisig/verifier/internal/sigutil"
itypes "github.com/vultisig/verifier/internal/types"
"github.com/vultisig/verifier/types"
vtypes "github.com/vultisig/verifier/types"
"github.com/vultisig/vultisig-go/address"
Expand Down Expand Up @@ -327,7 +329,22 @@ func (s *Server) GetPluginPolicyById(c echo.Context) error {
if policy.PublicKey != publicKey {
return c.JSON(http.StatusForbidden, NewErrorResponseWithMessage(msgPublicKeyMismatch))
}
return c.JSON(http.StatusOK, NewSuccessResponse(http.StatusOK, policy))
prog, err := s.pluginService.GetPolicyProgress(c.Request().Context(), policy.PluginID.String(), policyUUID)
if err != nil {
s.logger.WithError(err).Warnf("failed to get progress from plugin for policy %s", policyUUID)
}
if prog == nil {
count, countErr := s.txIndexerService.CountByPolicyID(c.Request().Context(), policyUUID)
if countErr != nil {
s.logger.WithError(countErr).Warnf("failed to get tx count for policy %s", policyUUID)
}
prog = &itypes.Progress{Kind: itypes.ProgressCounter, Value: count}
}
resp := itypes.PluginPolicyResponse{
PluginPolicy: *policy,
Progress: *prog,
}
return c.JSON(http.StatusOK, NewSuccessResponse(http.StatusOK, resp))
}

func (s *Server) GetAllPluginPolicies(c echo.Context) error {
Expand Down Expand Up @@ -368,5 +385,49 @@ func (s *Server) GetAllPluginPolicies(c echo.Context) error {
return c.JSON(http.StatusInternalServerError, NewErrorResponseWithMessage(msgPoliciesGetFailed))
}

return c.JSON(http.StatusOK, NewSuccessResponse(http.StatusOK, policies))
policyIDs := make([]uuid.UUID, len(policies.Policies))
for i, p := range policies.Policies {
policyIDs[i] = p.ID
}

progressMap, err := s.pluginService.GetPoliciesProgress(c.Request().Context(), pluginID, policyIDs)
if err != nil {
s.logger.WithError(err).Warnf("failed to get progress from plugin for plugin %s", pluginID)
}

responses := make([]itypes.PluginPolicyResponse, len(policies.Policies))
eg, egCtx := errgroup.WithContext(c.Request().Context())
for i, p := range policies.Policies {
i, p := i, p
eg.Go(func() error {
var prog *itypes.Progress
if progressMap != nil {
if pluginProg, ok := progressMap[p.ID]; ok {
prog = pluginProg
}
}
if prog == nil {
count, countErr := s.txIndexerService.CountByPolicyID(egCtx, p.ID)
if countErr != nil {
s.logger.WithError(countErr).Warnf("failed to get tx count for policy %s", p.ID)
}
prog = &itypes.Progress{Kind: itypes.ProgressCounter, Value: count}
}
responses[i] = itypes.PluginPolicyResponse{
PluginPolicy: p,
Progress: *prog,
}
return nil
})
}
if err := eg.Wait(); err != nil {
s.logger.WithError(err).Error("failed to get policy progress")
return c.JSON(http.StatusInternalServerError, NewErrorResponseWithMessage(msgInternalError))
}

result := itypes.PluginPolicyResponsePaginatedList{
Policies: responses,
TotalCount: policies.TotalCount,
}
return c.JSON(http.StatusOK, NewSuccessResponse(http.StatusOK, result))
}
12 changes: 6 additions & 6 deletions internal/portal/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ func (s *Server) GetPluginPricings(c echo.Context) error {
}
response[i] = PluginPricingResponse{
ID: p.ID.String(),
PluginID: string(p.PluginID),
PluginID: p.PluginID,
Type: string(p.Type),
Frequency: freq,
Amount: strconv.FormatInt(p.Amount, 10),
Expand Down Expand Up @@ -442,7 +442,7 @@ func (s *Server) GetPluginApiKeys(c echo.Context) error {
}
response[i] = PluginApiKeyResponse{
ID: k.ID.String(),
PluginID: string(k.PluginID),
PluginID: k.PluginID,
ApiKey: maskApiKey(k.Apikey),
CreatedAt: k.CreatedAt.Time.Format(time.RFC3339),
ExpiresAt: expiresAt,
Expand Down Expand Up @@ -605,7 +605,7 @@ func (s *Server) CreatePluginApiKey(c echo.Context) error {
// Return the full API key (only shown once)
return c.JSON(http.StatusCreated, CreateApiKeyResponse{
ID: created.ID.String(),
PluginID: string(created.PluginID),
PluginID: created.PluginID,
ApiKey: apiKey, // Full key returned only on creation
CreatedAt: created.CreatedAt.Time.Format(time.RFC3339),
ExpiresAt: expiresAtStr,
Expand Down Expand Up @@ -675,7 +675,7 @@ func (s *Server) UpdatePluginApiKey(c echo.Context) error {
s.logger.WithError(err).Error("failed to get API key")
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
if string(existingKey.PluginID) != pluginID {
if existingKey.PluginID != pluginID {
return c.JSON(http.StatusForbidden, map[string]string{"error": "API key does not belong to this plugin"})
}

Expand Down Expand Up @@ -803,7 +803,7 @@ func (s *Server) DeletePluginApiKey(c echo.Context) error {
s.logger.WithError(err).Error("failed to get API key")
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
if string(existingKey.PluginID) != pluginID {
if existingKey.PluginID != pluginID {
return c.JSON(http.StatusForbidden, map[string]string{"error": "API key does not belong to this plugin"})
}

Expand All @@ -827,7 +827,7 @@ func (s *Server) DeletePluginApiKey(c echo.Context) error {

return c.JSON(http.StatusOK, PluginApiKeyResponse{
ID: expired.ID.String(),
PluginID: string(expired.PluginID),
PluginID: expired.PluginID,
ApiKey: maskApiKey(expired.Apikey),
CreatedAt: expired.CreatedAt.Time.Format(time.RFC3339),
ExpiresAt: expiresAt,
Expand Down
81 changes: 81 additions & 0 deletions internal/service/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"time"

"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/sirupsen/logrus"
rtypes "github.com/vultisig/recipes/types"
Expand Down Expand Up @@ -39,6 +41,8 @@ type Plugin interface {
GetPluginRecipeFunctions(ctx context.Context, pluginID string) (types.RecipeFunctions, error)
GetPluginTitlesByIDs(ctx context.Context, ids []string) (map[string]string, error)
GetPluginSkills(ctx context.Context, pluginID string) (*PluginSkills, error)
GetPolicyProgress(ctx context.Context, pluginID string, policyID uuid.UUID) (*types.Progress, error)
GetPoliciesProgress(ctx context.Context, pluginID string, policyIDs []uuid.UUID) (map[uuid.UUID]*types.Progress, error)
}

type PluginServiceStorage interface {
Expand Down Expand Up @@ -434,6 +438,83 @@ func (s *PluginService) GetPluginSkills(ctx context.Context, pluginID string) (*
return skills, nil
}

func (s *PluginService) GetPolicyProgress(ctx context.Context, pluginID string, policyID uuid.UUID) (*types.Progress, error) {
plugin, err := s.db.FindPluginById(ctx, nil, ptypes.PluginID(pluginID))
if err != nil {
return nil, fmt.Errorf("failed to find plugin: %w", err)
}
keyInfo, err := s.db.GetAPIKeyByPluginId(ctx, pluginID)
if err != nil || keyInfo == nil {
return nil, fmt.Errorf("failed to find plugin server info: %w", err)
}

url := fmt.Sprintf("%s/plugin/policy/%s/progress", strings.TrimSuffix(plugin.ServerEndpoint, "/"), policyID)

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+keyInfo.ApiKey)

client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call plugin endpoint: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusNotImplemented || resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("plugin endpoint returned status %d", resp.StatusCode)
}

var prog types.Progress
err = json.NewDecoder(resp.Body).Decode(&prog)
if err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}

return &prog, nil
}

func (s *PluginService) GetPoliciesProgress(ctx context.Context, pluginID string, policyIDs []uuid.UUID) (map[uuid.UUID]*types.Progress, error) {
plugin, err := s.db.FindPluginById(ctx, nil, ptypes.PluginID(pluginID))
if err != nil {
return nil, fmt.Errorf("failed to find plugin: %w", err)
}
keyInfo, err := s.db.GetAPIKeyByPluginId(ctx, pluginID)
if err != nil || keyInfo == nil {
return nil, fmt.Errorf("failed to find plugin server info: %w", err)
}

url := fmt.Sprintf("%s/plugin/policies/progress", strings.TrimSuffix(plugin.ServerEndpoint, "/"))

result, err := libhttp.Call[map[uuid.UUID]*types.Progress](
ctx,
http.MethodPost,
url,
map[string]string{
"Content-Type": "application/json",
"Authorization": "Bearer " + keyInfo.ApiKey,
},
struct {
PolicyIDs []uuid.UUID `json:"policy_ids"`
}{PolicyIDs: policyIDs},
nil,
)
if err != nil {
var httpErr *libhttp.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotImplemented || httpErr.StatusCode == http.StatusNotFound) {
return nil, nil
}
return nil, fmt.Errorf("failed to get policies progress: %w", err)
}

return result, nil
}

func (s *PluginService) fetchSkillsFromPlugin(ctx context.Context, serverEndpoint, token string) (*PluginSkills, error) {
url := fmt.Sprintf("%s/skills", strings.TrimSuffix(serverEndpoint, "/"))

Expand Down
20 changes: 20 additions & 0 deletions internal/types/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import (
"github.com/vultisig/verifier/types"
)

const (
ProgressPercent = "percent"
ProgressCounter = "counter"
)

type Plugin struct {
ID types.PluginID `json:"id" validate:"required"`
Title string `json:"title" validate:"required"`
Expand Down Expand Up @@ -83,6 +88,21 @@ type PluginPolicyPaginatedList struct {
TotalCount int `json:"total_count" validate:"required"`
}

type Progress struct {
Kind string `json:"kind"`
Value uint32 `json:"value"`
}

type PluginPolicyResponse struct {
types.PluginPolicy
Progress Progress `json:"progress"`
}

type PluginPolicyResponsePaginatedList struct {
Policies []PluginPolicyResponse `json:"policies"`
TotalCount int `json:"total_count"`
}

type PluginTotalCount struct {
ID types.PluginID `json:"id" validate:"required"`
TotalCount int `json:"total_count" validate:"required"`
Expand Down
31 changes: 31 additions & 0 deletions internal/types/transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type PluginTransactionResponse struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
BroadcastedAt *time.Time `json:"broadcasted_at"`
Progress Progress `json:"progress"`
}

// FromStorageTxs converts a slice of storage.Tx to a slice of PluginTransactionResponse
Expand All @@ -55,11 +56,41 @@ func FromStorageTxs(txs []storage.Tx, titleMap map[string]string) []PluginTransa
CreatedAt: tx.CreatedAt,
UpdatedAt: tx.UpdatedAt,
BroadcastedAt: tx.BroadcastedAt,
Progress: Progress{
ProgressPercent,
getProgress(tx.Status, tx.StatusOnChain, tx.BroadcastedAt),
},
}
}
return result
}

func getProgress(status storage.TxStatus, statusOnchain *rpc.TxOnChainStatus, broadcastedAt *time.Time) uint32 {
switch status {
case storage.TxProposed:
return 20
case storage.TxVerified:
return 40
case storage.TxSigned:
if statusOnchain == nil {
return 60
}
switch *statusOnchain {
case rpc.TxOnChainSuccess, rpc.TxOnChainFail:
return 100
case rpc.TxOnChainPending:
if broadcastedAt != nil {
return 80
}
return 60
default:
return 60
}
default:
return 0
}
}

type TransactionHistoryPaginatedList struct {
History []PluginTransactionResponse `json:"history"`
TotalCount uint32 `json:"total_count"`
Expand Down
Loading