Add full monorepo: virtual-banker, backend, frontend, docs, scripts, deployment
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
150
backend/auth/auth.go
Normal file
150
backend/auth/auth.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Auth handles user authentication
|
||||
type Auth struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewAuth creates a new auth handler
|
||||
func NewAuth(db *pgxpool.Pool) *Auth {
|
||||
return &Auth{db: db}
|
||||
}
|
||||
|
||||
// User represents a user
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
Username string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// RegisterUser registers a new user
|
||||
func (a *Auth) RegisterUser(ctx context.Context, email, username, password string) (*User, error) {
|
||||
// Hash password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Insert user
|
||||
query := `
|
||||
INSERT INTO users (email, username, password_hash)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, username, created_at
|
||||
`
|
||||
|
||||
var user User
|
||||
err = a.db.QueryRow(ctx, query, email, username, hashedPassword).Scan(
|
||||
&user.ID, &user.Email, &user.Username, &user.CreatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// AuthenticateUser authenticates a user
|
||||
func (a *Auth) AuthenticateUser(ctx context.Context, email, password string) (*User, error) {
|
||||
var user User
|
||||
var passwordHash string
|
||||
|
||||
query := `SELECT id, email, username, password_hash, created_at FROM users WHERE email = $1`
|
||||
err := a.db.QueryRow(ctx, query, email).Scan(
|
||||
&user.ID, &user.Email, &user.Username, &passwordHash, &user.CreatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GenerateAPIKey generates a new API key for a user
|
||||
func (a *Auth) GenerateAPIKey(ctx context.Context, userID, name string, tier string) (string, error) {
|
||||
// Generate random key
|
||||
keyBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(keyBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate key: %w", err)
|
||||
}
|
||||
|
||||
apiKey := "ek_" + hex.EncodeToString(keyBytes)
|
||||
|
||||
// Hash key for storage
|
||||
hashedKey := sha256.Sum256([]byte(apiKey))
|
||||
hashedKeyHex := hex.EncodeToString(hashedKey[:])
|
||||
|
||||
// Determine rate limits based on tier
|
||||
var rateLimitPerSecond, rateLimitPerMinute int
|
||||
switch tier {
|
||||
case "free":
|
||||
rateLimitPerSecond = 5
|
||||
rateLimitPerMinute = 100
|
||||
case "pro":
|
||||
rateLimitPerSecond = 20
|
||||
rateLimitPerMinute = 1000
|
||||
case "enterprise":
|
||||
rateLimitPerSecond = 100
|
||||
rateLimitPerMinute = 10000
|
||||
default:
|
||||
rateLimitPerSecond = 5
|
||||
rateLimitPerMinute = 100
|
||||
}
|
||||
|
||||
// Store API key
|
||||
query := `
|
||||
INSERT INTO api_keys (user_id, key_hash, name, tier, rate_limit_per_second, rate_limit_per_minute)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`
|
||||
|
||||
_, err := a.db.Exec(ctx, query, userID, hashedKeyHex, name, tier, rateLimitPerSecond, rateLimitPerMinute)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to store API key: %w", err)
|
||||
}
|
||||
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
// ValidateAPIKey validates an API key
|
||||
func (a *Auth) ValidateAPIKey(ctx context.Context, apiKey string) (string, error) {
|
||||
hashedKey := sha256.Sum256([]byte(apiKey))
|
||||
hashedKeyHex := hex.EncodeToString(hashedKey[:])
|
||||
|
||||
var userID string
|
||||
var revoked bool
|
||||
query := `SELECT user_id, revoked FROM api_keys WHERE key_hash = $1`
|
||||
err := a.db.QueryRow(ctx, query, hashedKeyHex).Scan(&userID, &revoked)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid API key")
|
||||
}
|
||||
|
||||
if revoked {
|
||||
return "", fmt.Errorf("API key revoked")
|
||||
}
|
||||
|
||||
// Update last used
|
||||
a.db.Exec(ctx, `UPDATE api_keys SET last_used_at = NOW() WHERE key_hash = $1`, hashedKeyHex)
|
||||
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
182
backend/auth/roles.go
Normal file
182
backend/auth/roles.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// RoleManager handles role-based access control
|
||||
type RoleManager struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRoleManager creates a new role manager
|
||||
func NewRoleManager(db *pgxpool.Pool) *RoleManager {
|
||||
return &RoleManager{db: db}
|
||||
}
|
||||
|
||||
// UserRole represents a user's role and track assignment
|
||||
type UserRole struct {
|
||||
Address string
|
||||
Track int
|
||||
Roles []string
|
||||
Approved bool
|
||||
ApprovedBy string
|
||||
ApprovedAt time.Time
|
||||
}
|
||||
|
||||
// AssignTrack assigns a track level to a user address
|
||||
func (r *RoleManager) AssignTrack(ctx context.Context, address string, track int, approvedBy string) error {
|
||||
if track < 1 || track > 4 {
|
||||
return fmt.Errorf("invalid track level: %d (must be 1-4)", track)
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO operator_roles (address, track_level, approved, approved_by, approved_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (address) DO UPDATE SET
|
||||
track_level = EXCLUDED.track_level,
|
||||
approved = EXCLUDED.approved,
|
||||
approved_by = EXCLUDED.approved_by,
|
||||
approved_at = EXCLUDED.approved_at,
|
||||
updated_at = NOW()
|
||||
`
|
||||
|
||||
_, err := r.db.Exec(ctx, query, address, track, true, approvedBy, time.Now())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to assign track: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserRole gets the role and track for a user address
|
||||
func (r *RoleManager) GetUserRole(ctx context.Context, address string) (*UserRole, error) {
|
||||
var role UserRole
|
||||
query := `
|
||||
SELECT address, track_level, roles, approved, approved_by, approved_at
|
||||
FROM operator_roles
|
||||
WHERE address = $1
|
||||
`
|
||||
|
||||
err := r.db.QueryRow(ctx, query, address).Scan(
|
||||
&role.Address,
|
||||
&role.Track,
|
||||
&role.Roles,
|
||||
&role.Approved,
|
||||
&role.ApprovedBy,
|
||||
&role.ApprovedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
// User not found, return default Track 1
|
||||
return &UserRole{
|
||||
Address: address,
|
||||
Track: 1,
|
||||
Roles: []string{},
|
||||
Approved: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &role, nil
|
||||
}
|
||||
|
||||
// ApproveUser approves a user for their assigned track
|
||||
func (r *RoleManager) ApproveUser(ctx context.Context, address string, approvedBy string) error {
|
||||
query := `
|
||||
UPDATE operator_roles
|
||||
SET approved = TRUE,
|
||||
approved_by = $2,
|
||||
approved_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE address = $1
|
||||
`
|
||||
|
||||
result, err := r.db.Exec(ctx, query, address, approvedBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to approve user: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeUser revokes a user's approval
|
||||
func (r *RoleManager) RevokeUser(ctx context.Context, address string) error {
|
||||
query := `
|
||||
UPDATE operator_roles
|
||||
SET approved = FALSE,
|
||||
approved_at = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE address = $1
|
||||
`
|
||||
|
||||
result, err := r.db.Exec(ctx, query, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to revoke user: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddIPWhitelist adds an IP address to the whitelist for an operator
|
||||
func (r *RoleManager) AddIPWhitelist(ctx context.Context, operatorAddress string, ipAddress string, description string) error {
|
||||
query := `
|
||||
INSERT INTO operator_ip_whitelist (operator_address, ip_address, description)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (operator_address, ip_address) DO UPDATE SET
|
||||
description = EXCLUDED.description
|
||||
`
|
||||
|
||||
_, err := r.db.Exec(ctx, query, operatorAddress, ipAddress, description)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add IP to whitelist: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsIPWhitelisted checks if an IP address is whitelisted for an operator
|
||||
func (r *RoleManager) IsIPWhitelisted(ctx context.Context, operatorAddress string, ipAddress string) (bool, error) {
|
||||
var count int
|
||||
query := `
|
||||
SELECT COUNT(*)
|
||||
FROM operator_ip_whitelist
|
||||
WHERE operator_address = $1 AND ip_address = $2
|
||||
`
|
||||
|
||||
err := r.db.QueryRow(ctx, query, operatorAddress, ipAddress).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check IP whitelist: %w", err)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// LogOperatorEvent logs an operator event for audit purposes
|
||||
func (r *RoleManager) LogOperatorEvent(ctx context.Context, eventType string, chainID *int, operatorAddress string, targetResource string, action string, details map[string]interface{}, ipAddress string, userAgent string) error {
|
||||
query := `
|
||||
INSERT INTO operator_events (event_type, chain_id, operator_address, target_resource, action, details, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`
|
||||
|
||||
// Convert details map to JSONB
|
||||
detailsJSON := map[string]interface{}(details)
|
||||
|
||||
_, err := r.db.Exec(ctx, query, eventType, chainID, operatorAddress, targetResource, action, detailsJSON, ipAddress, userAgent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to log operator event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
288
backend/auth/wallet_auth.go
Normal file
288
backend/auth/wallet_auth.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/accounts"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// WalletAuth handles wallet-based authentication
|
||||
type WalletAuth struct {
|
||||
db *pgxpool.Pool
|
||||
jwtSecret []byte
|
||||
}
|
||||
|
||||
// NewWalletAuth creates a new wallet auth handler
|
||||
func NewWalletAuth(db *pgxpool.Pool, jwtSecret []byte) *WalletAuth {
|
||||
return &WalletAuth{
|
||||
db: db,
|
||||
jwtSecret: jwtSecret,
|
||||
}
|
||||
}
|
||||
|
||||
// NonceRequest represents a nonce request
|
||||
type NonceRequest struct {
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
// NonceResponse represents a nonce response
|
||||
type NonceResponse struct {
|
||||
Nonce string `json:"nonce"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// WalletAuthRequest represents a wallet authentication request
|
||||
type WalletAuthRequest struct {
|
||||
Address string `json:"address"`
|
||||
Signature string `json:"signature"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
// WalletAuthResponse represents a wallet authentication response
|
||||
type WalletAuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Track int `json:"track"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
// GenerateNonce generates a random nonce for wallet authentication
|
||||
func (w *WalletAuth) GenerateNonce(ctx context.Context, address string) (*NonceResponse, error) {
|
||||
// Validate address format
|
||||
if !common.IsHexAddress(address) {
|
||||
return nil, fmt.Errorf("invalid address format")
|
||||
}
|
||||
|
||||
// Normalize address to checksum format
|
||||
addr := common.HexToAddress(address)
|
||||
normalizedAddr := addr.Hex()
|
||||
|
||||
// Generate random nonce
|
||||
nonceBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(nonceBytes); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
nonce := hex.EncodeToString(nonceBytes)
|
||||
|
||||
// Store nonce in database with expiration (5 minutes)
|
||||
expiresAt := time.Now().Add(5 * time.Minute)
|
||||
query := `
|
||||
INSERT INTO wallet_nonces (address, nonce, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (address) DO UPDATE SET
|
||||
nonce = EXCLUDED.nonce,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
created_at = NOW()
|
||||
`
|
||||
_, err := w.db.Exec(ctx, query, normalizedAddr, nonce, expiresAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to store nonce: %w", err)
|
||||
}
|
||||
|
||||
return &NonceResponse{
|
||||
Nonce: nonce,
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AuthenticateWallet authenticates a wallet using signature
|
||||
func (w *WalletAuth) AuthenticateWallet(ctx context.Context, req *WalletAuthRequest) (*WalletAuthResponse, error) {
|
||||
// Validate address format
|
||||
if !common.IsHexAddress(req.Address) {
|
||||
return nil, fmt.Errorf("invalid address format")
|
||||
}
|
||||
|
||||
// Normalize address
|
||||
addr := common.HexToAddress(req.Address)
|
||||
normalizedAddr := addr.Hex()
|
||||
|
||||
// Verify nonce
|
||||
var storedNonce string
|
||||
var expiresAt time.Time
|
||||
query := `SELECT nonce, expires_at FROM wallet_nonces WHERE address = $1`
|
||||
err := w.db.QueryRow(ctx, query, normalizedAddr).Scan(&storedNonce, &expiresAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nonce not found or expired")
|
||||
}
|
||||
|
||||
if time.Now().After(expiresAt) {
|
||||
return nil, fmt.Errorf("nonce expired")
|
||||
}
|
||||
|
||||
if storedNonce != req.Nonce {
|
||||
return nil, fmt.Errorf("invalid nonce")
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
message := fmt.Sprintf("Sign this message to authenticate with SolaceScanScout Explorer.\n\nNonce: %s", req.Nonce)
|
||||
messageHash := accounts.TextHash([]byte(message))
|
||||
|
||||
sigBytes, err := hex.DecodeString(req.Signature[2:]) // Remove 0x prefix
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid signature format: %w", err)
|
||||
}
|
||||
|
||||
// Recover public key from signature
|
||||
if sigBytes[64] >= 27 {
|
||||
sigBytes[64] -= 27
|
||||
}
|
||||
|
||||
pubKey, err := crypto.SigToPub(messageHash, sigBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to recover public key: %w", err)
|
||||
}
|
||||
|
||||
recoveredAddr := crypto.PubkeyToAddress(*pubKey)
|
||||
if recoveredAddr.Hex() != normalizedAddr {
|
||||
return nil, fmt.Errorf("signature does not match address")
|
||||
}
|
||||
|
||||
// Get or create user and track level
|
||||
track, err := w.getUserTrack(ctx, normalizedAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user track: %w", err)
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, expiresAt, err := w.generateJWT(normalizedAddr, track)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate token: %w", err)
|
||||
}
|
||||
|
||||
// Delete used nonce
|
||||
w.db.Exec(ctx, `DELETE FROM wallet_nonces WHERE address = $1`, normalizedAddr)
|
||||
|
||||
// Get permissions for track
|
||||
permissions := getPermissionsForTrack(track)
|
||||
|
||||
return &WalletAuthResponse{
|
||||
Token: token,
|
||||
ExpiresAt: expiresAt,
|
||||
Track: track,
|
||||
Permissions: permissions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getUserTrack gets the track level for a user address
|
||||
func (w *WalletAuth) getUserTrack(ctx context.Context, address string) (int, error) {
|
||||
// Check if user exists in operator_roles (Track 4)
|
||||
var track int
|
||||
var approved bool
|
||||
query := `SELECT track_level, approved FROM operator_roles WHERE address = $1`
|
||||
err := w.db.QueryRow(ctx, query, address).Scan(&track, &approved)
|
||||
if err == nil && approved {
|
||||
return track, nil
|
||||
}
|
||||
|
||||
// Check if user is approved for Track 2 or 3
|
||||
// For now, default to Track 1 (public)
|
||||
// In production, you'd have an approval table
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// generateJWT generates a JWT token with track claim
|
||||
func (w *WalletAuth) generateJWT(address string, track int) (string, time.Time, error) {
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"address": address,
|
||||
"track": track,
|
||||
"exp": expiresAt.Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString(w.jwtSecret)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("failed to sign token: %w", err)
|
||||
}
|
||||
|
||||
return tokenString, expiresAt, nil
|
||||
}
|
||||
|
||||
// ValidateJWT validates a JWT token and returns the address and track
|
||||
func (w *WalletAuth) ValidateJWT(tokenString string) (string, int, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return w.jwtSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("failed to parse token: %w", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return "", 0, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return "", 0, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
address, ok := claims["address"].(string)
|
||||
if !ok {
|
||||
return "", 0, fmt.Errorf("address not found in token")
|
||||
}
|
||||
|
||||
trackFloat, ok := claims["track"].(float64)
|
||||
if !ok {
|
||||
return "", 0, fmt.Errorf("track not found in token")
|
||||
}
|
||||
|
||||
track := int(trackFloat)
|
||||
return address, track, nil
|
||||
}
|
||||
|
||||
// getPermissionsForTrack returns permissions for a track level
|
||||
func getPermissionsForTrack(track int) []string {
|
||||
permissions := []string{
|
||||
"explorer.read.blocks",
|
||||
"explorer.read.transactions",
|
||||
"explorer.read.address.basic",
|
||||
"explorer.read.bridge.status",
|
||||
"weth.wrap",
|
||||
"weth.unwrap",
|
||||
}
|
||||
|
||||
if track >= 2 {
|
||||
permissions = append(permissions,
|
||||
"explorer.read.address.full",
|
||||
"explorer.read.tokens",
|
||||
"explorer.read.tx_history",
|
||||
"explorer.read.internal_txs",
|
||||
"explorer.search.enhanced",
|
||||
)
|
||||
}
|
||||
|
||||
if track >= 3 {
|
||||
permissions = append(permissions,
|
||||
"analytics.read.flows",
|
||||
"analytics.read.bridge",
|
||||
"analytics.read.token_distribution",
|
||||
"analytics.read.address_risk",
|
||||
)
|
||||
}
|
||||
|
||||
if track >= 4 {
|
||||
permissions = append(permissions,
|
||||
"operator.read.bridge_events",
|
||||
"operator.read.validators",
|
||||
"operator.read.contracts",
|
||||
"operator.read.protocol_state",
|
||||
"operator.write.bridge_control",
|
||||
)
|
||||
}
|
||||
|
||||
return permissions
|
||||
}
|
||||
Reference in New Issue
Block a user