package managedkey import ( "context" "encoding/hex" "math/big" "path" "strings" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/vault/kv" "go.uber.org/zap" ) const defaultComponent = "vault key manager" type service struct { logger mlogger.Logger component string store kv.Client keyPrefix string } func newService(opts Options) (Service, error) { logger := opts.Logger if logger == nil { logger = zap.NewNop() } component := strings.TrimSpace(opts.Component) if component == "" { component = defaultComponent } store, err := kv.New(kv.Options{ Logger: logger, Config: kv.Config{ Address: opts.Config.Address, TokenEnv: opts.Config.TokenEnv, TokenFileEnv: opts.Config.TokenFileEnv, TokenFile: opts.Config.TokenFile, Namespace: opts.Config.Namespace, MountPath: opts.Config.MountPath, }, Component: component, }) if err != nil { return nil, err } keyPrefix := strings.Trim(strings.TrimSpace(opts.Config.KeyPrefix), "/") if keyPrefix == "" { keyPrefix = strings.Trim(strings.TrimSpace(opts.DefaultKeyPrefix), "/") } if keyPrefix == "" { keyPrefix = "wallets" } return &service{ logger: logger.Named("vault"), component: component, store: store, keyPrefix: keyPrefix, }, nil } func (s *service) CreateManagedWalletKey(ctx context.Context, walletRef, network string) (*ManagedWalletKey, error) { walletRef = strings.TrimSpace(walletRef) network = strings.TrimSpace(network) if walletRef == "" { s.logger.Warn("WalletRef missing for managed key creation", zap.String("network", network)) return nil, merrors.InvalidArgument(s.component+": walletRef is required", "wallet_ref") } if network == "" { s.logger.Warn("Network missing for managed key creation", zap.String("wallet_ref", walletRef)) return nil, merrors.InvalidArgument(s.component+": network is required", "network") } privateKey, err := crypto.GenerateKey() if err != nil { s.logger.Warn("Failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err)) return nil, merrors.Internal(s.component + ": failed to generate key: " + err.Error()) } privateKeyBytes := crypto.FromECDSA(privateKey) publicKey := privateKey.PublicKey publicKeyBytes := crypto.FromECDSAPub(&publicKey) publicKeyHex := hex.EncodeToString(publicKeyBytes) address := strings.ToLower(crypto.PubkeyToAddress(publicKey).Hex()) keyID := s.BuildKeyID(network, walletRef) payload := map[string]interface{}{ "private_key": hex.EncodeToString(privateKeyBytes), "public_key": hex.EncodeToString(publicKeyBytes), "address": address, "network": strings.ToLower(network), } if err := s.store.Put(ctx, keyID, payload); err != nil { s.logger.Warn("Failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err)) zeroBytes(privateKeyBytes) zeroBytes(publicKeyBytes) return nil, err } zeroBytes(privateKeyBytes) zeroBytes(publicKeyBytes) s.logger.Info("Managed wallet key created", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.String("address", address)) return &ManagedWalletKey{ KeyID: keyID, Address: address, PublicKey: publicKeyHex, }, nil } func (s *service) SignEVMTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) { if strings.TrimSpace(keyID) == "" { s.logger.Warn("Signing failed: empty key id") return nil, merrors.InvalidArgument(s.component+": keyID is required", "key_id") } if tx == nil { s.logger.Warn("Signing failed: nil transaction", zap.String("key_id", keyID)) return nil, merrors.InvalidArgument(s.component+": transaction is nil", "transaction") } if chainID == nil { s.logger.Warn("Signing failed: nil chain id", zap.String("key_id", keyID)) return nil, merrors.InvalidArgument(s.component+": chainID is nil", "chain_id") } material, err := s.LoadKeyMaterial(ctx, keyID) if err != nil { s.logger.Warn("Failed to load key material", zap.String("key_id", keyID), zap.Error(err)) return nil, err } keyBytes, err := hex.DecodeString(material.PrivateKey) if err != nil { s.logger.Warn("Invalid key material", zap.String("key_id", keyID), zap.Error(err)) return nil, merrors.Internal(s.component + ": invalid key material: " + err.Error()) } defer zeroBytes(keyBytes) privateKey, err := crypto.ToECDSA(keyBytes) if err != nil { s.logger.Warn("Failed to construct private key", zap.String("key_id", keyID), zap.Error(err)) return nil, merrors.Internal(s.component + ": failed to construct private key: " + err.Error()) } signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey) if err != nil { s.logger.Warn("Failed to sign transaction", zap.String("key_id", keyID), zap.Error(err)) return nil, merrors.Internal(s.component + ": failed to sign transaction: " + err.Error()) } s.logger.Info("Transaction signed with managed key", zap.String("key_id", keyID), zap.String("network", material.Network), zap.String("tx_hash", signed.Hash().Hex())) return signed, nil } func (s *service) LoadKeyMaterial(ctx context.Context, keyID string) (*Material, error) { data, err := s.store.Get(ctx, keyID) if err != nil { return nil, err } secretPath := strings.Trim(strings.TrimPrefix(strings.TrimSpace(keyID), "/"), "/") privateKey, err := fieldAsString(data, "private_key", secretPath, s.component) if err != nil { return nil, err } publicKey, err := fieldAsString(data, "public_key", secretPath, s.component) if err != nil { return nil, err } address, err := fieldAsString(data, "address", secretPath, s.component) if err != nil { return nil, err } network, err := fieldAsString(data, "network", secretPath, s.component) if err != nil { return nil, err } return &Material{ PrivateKey: privateKey, PublicKey: publicKey, Address: address, Network: network, }, nil } func (s *service) BuildKeyID(network, walletRef string) string { net := strings.Trim(strings.ToLower(strings.TrimSpace(network)), "/") ref := strings.Trim(strings.TrimSpace(walletRef), "/") return path.Join(s.keyPrefix, net, ref) } func fieldAsString(data map[string]interface{}, field, secretPath, component string) (string, error) { value, ok := data[field] if !ok { return "", merrors.Internal(component + ": secret " + secretPath + " missing " + field) } str, ok := value.(string) if !ok || strings.TrimSpace(str) == "" { return "", merrors.Internal(component + ": secret " + secretPath + " invalid " + field) } return str, nil } func zeroBytes(data []byte) { for i := range data { data[i] = 0 } } var _ Service = (*service)(nil)