package http import ( "context" "encoding/hex" "net/http" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/rwadurian/mpc-system/pkg/logger" grpcclient "github.com/rwadurian/mpc-system/services/account/adapters/output/grpc" "github.com/rwadurian/mpc-system/services/account/application/ports" "github.com/rwadurian/mpc-system/services/account/application/use_cases" "github.com/rwadurian/mpc-system/services/account/domain/entities" "github.com/rwadurian/mpc-system/services/account/domain/repositories" "github.com/rwadurian/mpc-system/services/account/domain/value_objects" "go.uber.org/zap" ) // AccountHTTPHandler handles HTTP requests for accounts type AccountHTTPHandler struct { accountRepo repositories.AccountRepository sessionEventRepo repositories.SessionEventRepository createAccountUC *use_cases.CreateAccountUseCase getAccountUC *use_cases.GetAccountUseCase updateAccountUC *use_cases.UpdateAccountUseCase listAccountsUC *use_cases.ListAccountsUseCase getAccountSharesUC *use_cases.GetAccountSharesUseCase deactivateShareUC *use_cases.DeactivateShareUseCase loginUC *use_cases.LoginUseCase refreshTokenUC *use_cases.RefreshTokenUseCase generateChallengeUC *use_cases.GenerateChallengeUseCase initiateRecoveryUC *use_cases.InitiateRecoveryUseCase completeRecoveryUC *use_cases.CompleteRecoveryUseCase getRecoveryStatusUC *use_cases.GetRecoveryStatusUseCase cancelRecoveryUC *use_cases.CancelRecoveryUseCase sessionCoordinatorClient *grpcclient.SessionCoordinatorClient } // NewAccountHTTPHandler creates a new AccountHTTPHandler func NewAccountHTTPHandler( accountRepo repositories.AccountRepository, sessionEventRepo repositories.SessionEventRepository, createAccountUC *use_cases.CreateAccountUseCase, getAccountUC *use_cases.GetAccountUseCase, updateAccountUC *use_cases.UpdateAccountUseCase, listAccountsUC *use_cases.ListAccountsUseCase, getAccountSharesUC *use_cases.GetAccountSharesUseCase, deactivateShareUC *use_cases.DeactivateShareUseCase, loginUC *use_cases.LoginUseCase, refreshTokenUC *use_cases.RefreshTokenUseCase, generateChallengeUC *use_cases.GenerateChallengeUseCase, initiateRecoveryUC *use_cases.InitiateRecoveryUseCase, completeRecoveryUC *use_cases.CompleteRecoveryUseCase, getRecoveryStatusUC *use_cases.GetRecoveryStatusUseCase, cancelRecoveryUC *use_cases.CancelRecoveryUseCase, sessionCoordinatorClient *grpcclient.SessionCoordinatorClient, ) *AccountHTTPHandler { return &AccountHTTPHandler{ accountRepo: accountRepo, sessionEventRepo: sessionEventRepo, createAccountUC: createAccountUC, getAccountUC: getAccountUC, updateAccountUC: updateAccountUC, listAccountsUC: listAccountsUC, getAccountSharesUC: getAccountSharesUC, deactivateShareUC: deactivateShareUC, loginUC: loginUC, refreshTokenUC: refreshTokenUC, generateChallengeUC: generateChallengeUC, initiateRecoveryUC: initiateRecoveryUC, completeRecoveryUC: completeRecoveryUC, getRecoveryStatusUC: getRecoveryStatusUC, cancelRecoveryUC: cancelRecoveryUC, sessionCoordinatorClient: sessionCoordinatorClient, } } // RegisterRoutes registers HTTP routes func (h *AccountHTTPHandler) RegisterRoutes(router *gin.RouterGroup) { accounts := router.Group("/accounts") { accounts.POST("", h.CreateAccount) accounts.POST("/from-keygen", h.CreateAccountFromKeygen) accounts.GET("", h.ListAccounts) accounts.GET("/:id", h.GetAccount) accounts.PUT("/:id", h.UpdateAccount) accounts.GET("/:id/shares", h.GetAccountShares) accounts.DELETE("/:id/shares/:shareId", h.DeactivateShare) // Signing parties configuration (use username as path parameter) accounts.POST("/by-username/:username/signing-config", h.SetSigningParties) accounts.PUT("/by-username/:username/signing-config", h.UpdateSigningParties) accounts.DELETE("/by-username/:username/signing-config", h.ClearSigningParties) accounts.GET("/by-username/:username/signing-config", h.GetSigningParties) } auth := router.Group("/auth") { auth.POST("/challenge", h.GenerateChallenge) auth.POST("/login", h.Login) auth.POST("/refresh", h.RefreshToken) } recovery := router.Group("/recovery") { recovery.POST("", h.InitiateRecovery) recovery.GET("/:id", h.GetRecoveryStatus) recovery.POST("/:id/complete", h.CompleteRecovery) recovery.POST("/:id/cancel", h.CancelRecovery) } // MPC session management mpc := router.Group("/mpc") { mpc.POST("/keygen", h.CreateKeygenSession) mpc.POST("/sign", h.CreateSigningSession) mpc.GET("/sessions/:id", h.GetSessionStatus) } } // CreateAccountRequest represents the request for creating an account type CreateAccountRequest struct { Username string `json:"username" binding:"required"` Email string `json:"email" binding:"omitempty,email"` Phone *string `json:"phone"` PublicKey string `json:"publicKey" binding:"required"` KeygenSessionID string `json:"keygenSessionId" binding:"required"` ThresholdN int `json:"thresholdN" binding:"required,min=1"` ThresholdT int `json:"thresholdT" binding:"required,min=1"` Shares []ShareInput `json:"shares" binding:"required,min=1"` } // ShareInput represents a share in the request type ShareInput struct { ShareType string `json:"shareType" binding:"required"` PartyID string `json:"partyId" binding:"required"` PartyIndex int `json:"partyIndex" binding:"required,min=0"` DeviceType *string `json:"deviceType"` DeviceID *string `json:"deviceId"` } // CreateAccount handles account creation func (h *AccountHTTPHandler) CreateAccount(c *gin.Context) { var req CreateAccountRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } keygenSessionID, err := uuid.Parse(req.KeygenSessionID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid keygen session ID"}) return } shares := make([]ports.ShareInput, len(req.Shares)) for i, s := range req.Shares { shares[i] = ports.ShareInput{ ShareType: value_objects.ShareType(s.ShareType), PartyID: s.PartyID, PartyIndex: s.PartyIndex, DeviceType: s.DeviceType, DeviceID: s.DeviceID, } } // Decode hex-encoded public key publicKeyBytes, err := hex.DecodeString(req.PublicKey) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid public key format"}) return } output, err := h.createAccountUC.Execute(c.Request.Context(), ports.CreateAccountInput{ Username: req.Username, Email: req.Email, Phone: req.Phone, PublicKey: publicKeyBytes, KeygenSessionID: keygenSessionID, ThresholdN: req.ThresholdN, ThresholdT: req.ThresholdT, Shares: shares, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{ "account": output.Account, "shares": output.Shares, }) } // GetAccount handles getting account by ID func (h *AccountHTTPHandler) GetAccount(c *gin.Context) { idStr := c.Param("id") accountID, err := value_objects.AccountIDFromString(idStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) return } output, err := h.getAccountUC.Execute(c.Request.Context(), ports.GetAccountInput{ AccountID: &accountID, }) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "account": output.Account, "shares": output.Shares, }) } // UpdateAccountRequest represents the request for updating an account type UpdateAccountRequest struct { Phone *string `json:"phone"` } // UpdateAccount handles account updates func (h *AccountHTTPHandler) UpdateAccount(c *gin.Context) { idStr := c.Param("id") accountID, err := value_objects.AccountIDFromString(idStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) return } var req UpdateAccountRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } output, err := h.updateAccountUC.Execute(c.Request.Context(), ports.UpdateAccountInput{ AccountID: accountID, Phone: req.Phone, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, output.Account) } // ListAccounts handles listing accounts func (h *AccountHTTPHandler) ListAccounts(c *gin.Context) { var offset, limit int if o := c.Query("offset"); o != "" { // Parse offset } if l := c.Query("limit"); l != "" { // Parse limit } output, err := h.listAccountsUC.Execute(c.Request.Context(), use_cases.ListAccountsInput{ Offset: offset, Limit: limit, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "accounts": output.Accounts, "total": output.Total, }) } // GetAccountShares handles getting account shares func (h *AccountHTTPHandler) GetAccountShares(c *gin.Context) { idStr := c.Param("id") accountID, err := value_objects.AccountIDFromString(idStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) return } output, err := h.getAccountSharesUC.Execute(c.Request.Context(), accountID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "shares": output.Shares, }) } // DeactivateShare handles share deactivation func (h *AccountHTTPHandler) DeactivateShare(c *gin.Context) { idStr := c.Param("id") accountID, err := value_objects.AccountIDFromString(idStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) return } shareID := c.Param("shareId") err = h.deactivateShareUC.Execute(c.Request.Context(), ports.DeactivateShareInput{ AccountID: accountID, ShareID: shareID, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "share deactivated"}) } // GenerateChallengeRequest represents the request for generating a challenge type GenerateChallengeRequest struct { Username string `json:"username" binding:"required"` } // GenerateChallenge handles challenge generation func (h *AccountHTTPHandler) GenerateChallenge(c *gin.Context) { var req GenerateChallengeRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } output, err := h.generateChallengeUC.Execute(c.Request.Context(), use_cases.GenerateChallengeInput{ Username: req.Username, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "challengeId": output.ChallengeID, "challenge": hex.EncodeToString(output.Challenge), "expiresAt": output.ExpiresAt, }) } // LoginRequest represents the request for login type LoginRequest struct { Username string `json:"username" binding:"required"` Challenge string `json:"challenge" binding:"required"` Signature string `json:"signature" binding:"required"` } // Login handles user login func (h *AccountHTTPHandler) Login(c *gin.Context) { var req LoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Decode hex-encoded challenge and signature // Return 401 Unauthorized for invalid formats (treated as invalid credentials) challengeBytes, err := hex.DecodeString(req.Challenge) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) return } signatureBytes, err := hex.DecodeString(req.Signature) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) return } output, err := h.loginUC.Execute(c.Request.Context(), ports.LoginInput{ Username: req.Username, Challenge: challengeBytes, Signature: signatureBytes, }) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "account": output.Account, "accessToken": output.AccessToken, "refreshToken": output.RefreshToken, }) } // RefreshTokenRequest represents the request for refreshing tokens type RefreshTokenRequest struct { RefreshToken string `json:"refreshToken" binding:"required"` } // RefreshToken handles token refresh func (h *AccountHTTPHandler) RefreshToken(c *gin.Context) { var req RefreshTokenRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } output, err := h.refreshTokenUC.Execute(c.Request.Context(), use_cases.RefreshTokenInput{ RefreshToken: req.RefreshToken, }) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "accessToken": output.AccessToken, "refreshToken": output.RefreshToken, }) } // InitiateRecoveryRequest represents the request for initiating recovery type InitiateRecoveryRequest struct { AccountID string `json:"accountId" binding:"required"` RecoveryType string `json:"recoveryType" binding:"required"` OldShareType *string `json:"oldShareType"` } // InitiateRecovery handles recovery initiation func (h *AccountHTTPHandler) InitiateRecovery(c *gin.Context) { var req InitiateRecoveryRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } accountID, err := value_objects.AccountIDFromString(req.AccountID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) return } input := ports.InitiateRecoveryInput{ AccountID: accountID, RecoveryType: value_objects.RecoveryType(req.RecoveryType), } if req.OldShareType != nil { st := value_objects.ShareType(*req.OldShareType) input.OldShareType = &st } output, err := h.initiateRecoveryUC.Execute(c.Request.Context(), input) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{ "recoverySession": output.RecoverySession, }) } // GetRecoveryStatus handles getting recovery status func (h *AccountHTTPHandler) GetRecoveryStatus(c *gin.Context) { id := c.Param("id") output, err := h.getRecoveryStatusUC.Execute(c.Request.Context(), use_cases.GetRecoveryStatusInput{ RecoverySessionID: id, }) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, output.RecoverySession) } // CompleteRecoveryRequest represents the request for completing recovery type CompleteRecoveryRequest struct { NewPublicKey string `json:"newPublicKey" binding:"required"` NewKeygenSessionID string `json:"newKeygenSessionId" binding:"required"` NewShares []ShareInput `json:"newShares" binding:"required,min=1"` } // CompleteRecovery handles recovery completion func (h *AccountHTTPHandler) CompleteRecovery(c *gin.Context) { id := c.Param("id") var req CompleteRecoveryRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } newKeygenSessionID, err := uuid.Parse(req.NewKeygenSessionID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid keygen session ID"}) return } newShares := make([]ports.ShareInput, len(req.NewShares)) for i, s := range req.NewShares { newShares[i] = ports.ShareInput{ ShareType: value_objects.ShareType(s.ShareType), PartyID: s.PartyID, PartyIndex: s.PartyIndex, DeviceType: s.DeviceType, DeviceID: s.DeviceID, } } // Decode hex-encoded public key newPublicKeyBytes, err := hex.DecodeString(req.NewPublicKey) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid public key format"}) return } output, err := h.completeRecoveryUC.Execute(c.Request.Context(), ports.CompleteRecoveryInput{ RecoverySessionID: id, NewPublicKey: newPublicKeyBytes, NewKeygenSessionID: newKeygenSessionID, NewShares: newShares, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, output.Account) } // CancelRecovery handles recovery cancellation func (h *AccountHTTPHandler) CancelRecovery(c *gin.Context) { id := c.Param("id") err := h.cancelRecoveryUC.Execute(c.Request.Context(), use_cases.CancelRecoveryInput{ RecoverySessionID: id, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "recovery cancelled"}) } // ============================================ // MPC Session Management Endpoints // ============================================ // CreateKeygenSessionRequest represents the request for creating a keygen session // Coordinator will automatically select parties from registered pool type CreateKeygenSessionRequest struct { Username string `json:"username" binding:"required"` // Username - the unique identifier for all relationships ThresholdN int `json:"threshold_n" binding:"required,min=2"` // Total number of parties (e.g., 3) ThresholdT int `json:"threshold_t" binding:"required,min=1"` // Threshold for signing (e.g., 2) RequireDelegate bool `json:"require_delegate"` // If true, one party will be delegate (returns share to user) } // CreateKeygenSession handles creating a new keygen session // Parties are automatically selected by Coordinator from registered pool func (h *AccountHTTPHandler) CreateKeygenSession(c *gin.Context) { var req CreateKeygenSessionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Validate threshold if req.ThresholdT > req.ThresholdN { c.JSON(http.StatusBadRequest, gin.H{"error": "threshold_t cannot be greater than threshold_n"}) return } // Check if username already exists (keygen should be for new users) exists, err := h.accountRepo.ExistsByUsername(c.Request.Context(), req.Username) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check username"}) return } if exists { c.JSON(http.StatusConflict, gin.H{"error": "username already exists, use sign API instead"}) return } // Call session coordinator via gRPC (no participants - coordinator selects automatically) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() logger.Info("Calling CreateKeygenSession via gRPC (auto party selection)", zap.String("username", req.Username), zap.Int("threshold_n", req.ThresholdN), zap.Int("threshold_t", req.ThresholdT), zap.Bool("require_delegate", req.RequireDelegate)) // Calculate party composition based on require_delegate var persistentCount, delegateCount int if req.RequireDelegate { // One delegate party, rest are persistent delegateCount = 1 persistentCount = req.ThresholdN - 1 } else { // All persistent parties persistentCount = req.ThresholdN delegateCount = 0 } resp, err := h.sessionCoordinatorClient.CreateKeygenSessionAuto( ctx, int32(req.ThresholdN), int32(req.ThresholdT), int32(persistentCount), int32(delegateCount), 600, // 10 minutes expiry ) if err != nil { logger.Error("gRPC CreateKeygenSession failed", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } logger.Info("gRPC CreateKeygenSession succeeded", zap.String("session_id", resp.SessionID), zap.Int("num_parties", len(resp.SelectedParties))) // Record session_created event sessionID, _ := uuid.Parse(resp.SessionID) event := repositories.NewSessionEvent( sessionID, req.Username, repositories.EventSessionCreated, repositories.SessionTypeKeygen, ).WithThreshold(req.ThresholdN, req.ThresholdT).WithMetadata(map[string]interface{}{ "selected_parties": resp.SelectedParties, "delegate_party": resp.DelegateParty, "require_delegate": req.RequireDelegate, "persistent_count": persistentCount, "delegate_count": delegateCount, }) if err := h.sessionEventRepo.Create(c.Request.Context(), event); err != nil { logger.Error("Failed to record session event", zap.Error(err)) // Don't fail the request, just log the error } // Return response with selected parties info c.JSON(http.StatusCreated, gin.H{ "session_id": resp.SessionID, "session_type": "keygen", "username": req.Username, // Include username in response for reference "threshold_n": req.ThresholdN, "threshold_t": req.ThresholdT, "selected_parties": resp.SelectedParties, "delegate_party": resp.DelegateParty, // The party that will return share to user "status": "created", }) } // CreateSigningSessionRequest represents the request for creating a signing session // Coordinator will automatically select parties based on account's registered shares type CreateSigningSessionRequest struct { Username string `json:"username" binding:"required"` // Username - the unique identifier for all relationships MessageHash string `json:"message_hash" binding:"required"` // SHA-256 hash to sign (hex encoded) UserShare string `json:"user_share"` // Required if account has delegate share: user's encrypted share (hex) } // CreateSigningSession handles creating a new signing session // Parties are automatically selected based on the account's registered shares func (h *AccountHTTPHandler) CreateSigningSession(c *gin.Context) { var req CreateSigningSessionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Decode message hash messageHash, err := hex.DecodeString(req.MessageHash) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message_hash format (expected hex)"}) return } if len(messageHash) != 32 { c.JSON(http.StatusBadRequest, gin.H{"error": "message_hash must be 32 bytes (SHA-256)"}) return } // Get account by username to verify it exists and get share info accountOutput, err := h.getAccountUC.Execute(c.Request.Context(), ports.GetAccountInput{ Username: &req.Username, }) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "account not found for username: " + req.Username}) return } // Build a map of active shares for validation activeSharesMap := make(map[string]*entities.AccountShare) var allActivePartyIDs []string var delegateShare *entities.AccountShare for _, share := range accountOutput.Shares { if share.IsActive { activeSharesMap[share.PartyID] = share allActivePartyIDs = append(allActivePartyIDs, share.PartyID) // Check if this is a delegate share if share.ShareType == value_objects.ShareTypeDelegate { delegateShare = share } } } // Determine which parties to use for signing var signingParties []grpcclient.SigningPartyInfo var partyIDs []string // For logging and validation if accountOutput.Account.HasSigningPartiesConfig() { // Use configured signing parties configuredParties := accountOutput.Account.GetSigningParties() // Validate all configured parties are still active and build signing party info for _, partyID := range configuredParties { share, exists := activeSharesMap[partyID] if !exists { c.JSON(http.StatusBadRequest, gin.H{ "error": "configured signing party is no longer active", "party_id": partyID, "hint": "update signing-config with active parties", }) return } signingParties = append(signingParties, grpcclient.SigningPartyInfo{ PartyID: partyID, PartyIndex: int32(share.PartyIndex), }) partyIDs = append(partyIDs, partyID) } logger.Info("Using configured signing parties", zap.String("username", req.Username), zap.Strings("configured_parties", partyIDs)) } else { // For threshold signing, select minimum required parties (threshold_t + 1) // TSS threshold semantics: for threshold t, we need t+1 signers // For 2-of-3: t=1, so we need t+1=2 parties to sign requiredParties := accountOutput.Account.ThresholdT + 1 if len(allActivePartyIDs) < requiredParties { c.JSON(http.StatusBadRequest, gin.H{ "error": "insufficient active parties for signing", "required": requiredParties, "available": len(allActivePartyIDs), }) return } // Select first 'threshold_t + 1' parties with their original PartyIndex for i := 0; i < requiredParties; i++ { partyID := allActivePartyIDs[i] share := activeSharesMap[partyID] signingParties = append(signingParties, grpcclient.SigningPartyInfo{ PartyID: partyID, PartyIndex: int32(share.PartyIndex), }) partyIDs = append(partyIDs, partyID) } logger.Info("Using minimum required parties for threshold signing", zap.String("username", req.Username), zap.Int("threshold_t", accountOutput.Account.ThresholdT), zap.Int("required_signers", requiredParties), zap.Int("total_active", len(allActivePartyIDs)), zap.Strings("selected_parties", partyIDs)) } // Check if any of the selected parties is a delegate // (delegate share might not be in the configured signing parties) var selectedDelegateShare *entities.AccountShare for _, partyID := range partyIDs { if share := activeSharesMap[partyID]; share != nil && share.ShareType == value_objects.ShareTypeDelegate { selectedDelegateShare = share break } } // If selected parties include delegate share, user_share is required if selectedDelegateShare != nil && req.UserShare == "" { c.JSON(http.StatusBadRequest, gin.H{ "error": "user_share is required for accounts with delegate party", "delegate_party_id": selectedDelegateShare.PartyID, }) return } // Use the selected delegate for signing (not necessarily the account's delegate) delegateShare = selectedDelegateShare // Validate we have enough parties (T+1 required for TSS signing) requiredSigners := accountOutput.Account.ThresholdT + 1 if len(partyIDs) < requiredSigners { c.JSON(http.StatusBadRequest, gin.H{ "error": "insufficient parties for signing", "required": requiredSigners, "selected": len(partyIDs), }) return } // Call session coordinator via gRPC ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // Prepare delegate user share if needed var delegateUserShare *grpcclient.DelegateUserShareInput if delegateShare != nil { userShareBytes, err := hex.DecodeString(req.UserShare) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user_share format (expected hex)"}) return } delegateUserShare = &grpcclient.DelegateUserShareInput{ DelegatePartyID: delegateShare.PartyID, EncryptedShare: userShareBytes, PartyIndex: int32(delegateShare.PartyIndex), } logger.Info("Calling CreateSigningSession with delegate user share", zap.String("username", req.Username), zap.String("delegate_party_id", delegateShare.PartyID), zap.Int("threshold_t", accountOutput.Account.ThresholdT), zap.Int("available_parties", len(partyIDs))) } else { logger.Info("Calling CreateSigningSession via gRPC (auto party selection)", zap.String("username", req.Username), zap.Int("threshold_t", accountOutput.Account.ThresholdT), zap.Int("available_parties", len(partyIDs)), zap.String("keygen_session_id", accountOutput.Account.KeygenSessionID.String())) } resp, err := h.sessionCoordinatorClient.CreateSigningSessionAuto( ctx, int32(accountOutput.Account.ThresholdT), signingParties, messageHash, 600, // 10 minutes expiry delegateUserShare, accountOutput.Account.KeygenSessionID.String(), ) if err != nil { logger.Error("gRPC CreateSigningSession failed", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } logger.Info("gRPC CreateSigningSession succeeded", zap.String("session_id", resp.SessionID), zap.Int("num_parties", len(resp.SelectedParties)), zap.String("keygen_session_id", accountOutput.Account.KeygenSessionID.String()), zap.Strings("selected_parties", resp.SelectedParties)) // Record session_created event for sign signSessionID, _ := uuid.Parse(resp.SessionID) signEvent := repositories.NewSessionEvent( signSessionID, req.Username, repositories.EventSessionCreated, repositories.SessionTypeSign, ).WithThreshold(accountOutput.Account.ThresholdN, accountOutput.Account.ThresholdT). WithMessageHash(messageHash). WithMetadata(map[string]interface{}{ "selected_parties": resp.SelectedParties, "has_delegate": delegateShare != nil, "signing_parties_config": accountOutput.Account.HasSigningPartiesConfig(), }) if delegateShare != nil { signEvent = signEvent.WithParty(delegateShare.PartyID, delegateShare.PartyIndex) } if err := h.sessionEventRepo.Create(c.Request.Context(), signEvent); err != nil { logger.Error("Failed to record session event", zap.Error(err)) // Don't fail the request, just log the error } response := gin.H{ "session_id": resp.SessionID, "session_type": "sign", "username": req.Username, "message_hash": req.MessageHash, "threshold_t": accountOutput.Account.ThresholdT, "selected_parties": resp.SelectedParties, "status": "created", } // Include has_delegate for sign sessions too if delegateShare != nil { response["has_delegate"] = true response["delegate_party_id"] = delegateShare.PartyID } else { response["has_delegate"] = false } c.JSON(http.StatusCreated, response) } // GetSessionStatus handles getting session status func (h *AccountHTTPHandler) GetSessionStatus(c *gin.Context) { sessionID := c.Param("id") // Validate session ID format if _, err := uuid.Parse(sessionID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID format"}) return } // Call session coordinator via gRPC ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() resp, err := h.sessionCoordinatorClient.GetSessionStatus(ctx, sessionID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } response := gin.H{ "session_id": sessionID, "status": resp.Status, "session_type": resp.SessionType, // "keygen" or "sign" "completed_parties": resp.CompletedParties, "total_parties": resp.TotalParties, } // Keygen-specific fields if resp.SessionType == "keygen" { // has_delegate only meaningful for keygen sessions response["has_delegate"] = resp.HasDelegate if len(resp.PublicKey) > 0 { response["public_key"] = hex.EncodeToString(resp.PublicKey) } // Include delegate share if keygen session has delegate party // has_delegate=true + delegate_share=null means share was already retrieved (one-time) // has_delegate=false means no delegate party (pure persistent keygen) if resp.HasDelegate && resp.DelegateShare != nil { response["delegate_share"] = gin.H{ "encrypted_share": hex.EncodeToString(resp.DelegateShare.EncryptedShare), "party_index": resp.DelegateShare.PartyIndex, "party_id": resp.DelegateShare.PartyID, } } } // Sign-specific fields if resp.SessionType == "sign" { if len(resp.Signature) > 0 { response["signature"] = hex.EncodeToString(resp.Signature) } } c.JSON(http.StatusOK, response) } // ============================================ // Account Creation from Keygen (Internal API) // ============================================ // CreateAccountFromKeygenRequest represents the request from Session Coordinator // after keygen completion type CreateAccountFromKeygenRequest struct { PublicKey string `json:"public_key" binding:"required"` KeygenSessionID string `json:"keygen_session_id" binding:"required"` ThresholdN int `json:"threshold_n" binding:"required,min=2"` ThresholdT int `json:"threshold_t" binding:"required,min=1"` Shares []ShareInfoFromKeygenInput `json:"shares" binding:"required,min=1"` } // ShareInfoFromKeygenInput represents share info from keygen type ShareInfoFromKeygenInput struct { PartyID string `json:"party_id" binding:"required"` PartyIndex int `json:"party_index"` ShareType string `json:"share_type" binding:"required"` // "persistent" or "delegate" } // CreateAccountFromKeygen handles account creation after keygen completion // This is called by Session Coordinator when all parties complete keygen func (h *AccountHTTPHandler) CreateAccountFromKeygen(c *gin.Context) { var req CreateAccountFromKeygenRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Validate threshold if req.ThresholdT > req.ThresholdN { c.JSON(http.StatusBadRequest, gin.H{"error": "threshold_t cannot be greater than threshold_n"}) return } // Decode public key publicKey, err := hex.DecodeString(req.PublicKey) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid public key format"}) return } // Parse keygen session ID keygenSessionID, err := uuid.Parse(req.KeygenSessionID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid keygen_session_id format"}) return } // Generate a unique username based on keygen session ID // In production, you might want a different naming scheme username := "wallet-" + req.KeygenSessionID[:8] // Convert shares - map share type string to value_objects.ShareType shares := make([]ports.ShareInput, len(req.Shares)) for i, s := range req.Shares { var shareType value_objects.ShareType switch s.ShareType { case "persistent", "server": shareType = value_objects.ShareTypeServer case "delegate", "user_device": shareType = value_objects.ShareTypeUserDevice default: shareType = value_objects.ShareTypeServer } shares[i] = ports.ShareInput{ ShareType: shareType, PartyID: s.PartyID, PartyIndex: s.PartyIndex, } } logger.Info("Creating account from keygen", zap.String("keygen_session_id", req.KeygenSessionID), zap.String("username", username), zap.Int("threshold_n", req.ThresholdN), zap.Int("threshold_t", req.ThresholdT), zap.Int("num_shares", len(shares))) // Create account output, err := h.createAccountUC.Execute(c.Request.Context(), ports.CreateAccountInput{ Username: username, PublicKey: publicKey, KeygenSessionID: keygenSessionID, ThresholdN: req.ThresholdN, ThresholdT: req.ThresholdT, Shares: shares, }) if err != nil { logger.Error("Failed to create account from keygen", zap.String("keygen_session_id", req.KeygenSessionID), zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } logger.Info("Account created from keygen successfully", zap.String("account_id", output.Account.ID.String()), zap.String("username", output.Account.Username), zap.String("keygen_session_id", req.KeygenSessionID)) c.JSON(http.StatusCreated, gin.H{ "account_id": output.Account.ID.String(), "username": output.Account.Username, "public_key": hex.EncodeToString(output.Account.PublicKey), }) } // ============================================ // Signing Parties Configuration Endpoints // ============================================ // SigningPartiesRequest represents the request for setting/updating signing parties type SigningPartiesRequest struct { PartyIDs []string `json:"party_ids" binding:"required,min=1"` } // SetSigningParties handles setting signing parties for the first time // POST /accounts/by-username/:username/signing-config func (h *AccountHTTPHandler) SetSigningParties(c *gin.Context) { username := c.Param("username") if username == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "username is required"}) return } var req SigningPartiesRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Get account by username accountOutput, err := h.getAccountUC.Execute(c.Request.Context(), ports.GetAccountInput{ Username: &username, }) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "account not found for username: " + username}) return } // Check if signing parties already configured if accountOutput.Account.HasSigningPartiesConfig() { c.JSON(http.StatusConflict, gin.H{ "error": "signing parties already configured, use PUT to update", "current_signing_parties": accountOutput.Account.GetSigningParties(), }) return } // Validate that all party IDs are valid shares for this account validPartyIDs := make(map[string]bool) for _, share := range accountOutput.Shares { if share.IsActive { validPartyIDs[share.PartyID] = true } } for _, partyID := range req.PartyIDs { if !validPartyIDs[partyID] { c.JSON(http.StatusBadRequest, gin.H{ "error": "invalid party ID - not an active share for this account", "party_id": partyID, }) return } } // Set signing parties if err := accountOutput.Account.SetSigningParties(req.PartyIDs); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Update account in database _, err = h.updateAccountUC.Execute(c.Request.Context(), ports.UpdateAccountInput{ AccountID: accountOutput.Account.ID, SigningParties: req.PartyIDs, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } logger.Info("Signing parties configured", zap.String("username", username), zap.Strings("signing_parties", req.PartyIDs)) // Record signing_config_set event event := repositories.NewSessionEvent( uuid.New(), // Generate new event ID (no session for config changes) username, repositories.EventSigningConfigSet, repositories.SessionTypeSign, // Configuration is for signing ).WithThreshold(accountOutput.Account.ThresholdN, accountOutput.Account.ThresholdT). WithMetadata(map[string]interface{}{ "operation": "set", "signing_parties": req.PartyIDs, }) if err := h.sessionEventRepo.Create(c.Request.Context(), event); err != nil { logger.Error("Failed to record signing config event", zap.Error(err)) } c.JSON(http.StatusCreated, gin.H{ "message": "signing parties configured successfully", "username": username, "signing_parties": req.PartyIDs, "threshold_t": accountOutput.Account.ThresholdT, }) } // UpdateSigningParties handles updating existing signing parties configuration // PUT /accounts/by-username/:username/signing-config func (h *AccountHTTPHandler) UpdateSigningParties(c *gin.Context) { username := c.Param("username") if username == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "username is required"}) return } var req SigningPartiesRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Get account by username accountOutput, err := h.getAccountUC.Execute(c.Request.Context(), ports.GetAccountInput{ Username: &username, }) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "account not found for username: " + username}) return } // Check if signing parties are configured (must exist to update) if !accountOutput.Account.HasSigningPartiesConfig() { c.JSON(http.StatusNotFound, gin.H{ "error": "signing parties not configured, use POST to set", }) return } // Validate that all party IDs are valid shares for this account validPartyIDs := make(map[string]bool) for _, share := range accountOutput.Shares { if share.IsActive { validPartyIDs[share.PartyID] = true } } for _, partyID := range req.PartyIDs { if !validPartyIDs[partyID] { c.JSON(http.StatusBadRequest, gin.H{ "error": "invalid party ID - not an active share for this account", "party_id": partyID, }) return } } // Set signing parties (validates count matches threshold) if err := accountOutput.Account.SetSigningParties(req.PartyIDs); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Update account in database _, err = h.updateAccountUC.Execute(c.Request.Context(), ports.UpdateAccountInput{ AccountID: accountOutput.Account.ID, SigningParties: req.PartyIDs, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } logger.Info("Signing parties updated", zap.String("username", username), zap.Strings("signing_parties", req.PartyIDs)) // Record signing_config_set event for update updateEvent := repositories.NewSessionEvent( uuid.New(), username, repositories.EventSigningConfigSet, repositories.SessionTypeSign, ).WithThreshold(accountOutput.Account.ThresholdN, accountOutput.Account.ThresholdT). WithMetadata(map[string]interface{}{ "operation": "update", "signing_parties": req.PartyIDs, }) if err := h.sessionEventRepo.Create(c.Request.Context(), updateEvent); err != nil { logger.Error("Failed to record signing config event", zap.Error(err)) } c.JSON(http.StatusOK, gin.H{ "message": "signing parties updated successfully", "username": username, "signing_parties": req.PartyIDs, "threshold_t": accountOutput.Account.ThresholdT, }) } // ClearSigningParties handles clearing signing parties configuration // DELETE /accounts/by-username/:username/signing-config func (h *AccountHTTPHandler) ClearSigningParties(c *gin.Context) { username := c.Param("username") if username == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "username is required"}) return } // Get account by username accountOutput, err := h.getAccountUC.Execute(c.Request.Context(), ports.GetAccountInput{ Username: &username, }) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "account not found for username: " + username}) return } // Check if signing parties are configured if !accountOutput.Account.HasSigningPartiesConfig() { c.JSON(http.StatusNotFound, gin.H{ "error": "signing parties not configured", }) return } // Clear signing parties - pass empty slice _, err = h.updateAccountUC.Execute(c.Request.Context(), ports.UpdateAccountInput{ AccountID: accountOutput.Account.ID, ClearSigningParties: true, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } logger.Info("Signing parties cleared", zap.String("username", username)) // Record signing_config_cleared event clearEvent := repositories.NewSessionEvent( uuid.New(), username, repositories.EventSigningConfigCleared, repositories.SessionTypeSign, ).WithThreshold(accountOutput.Account.ThresholdN, accountOutput.Account.ThresholdT). WithMetadata(map[string]interface{}{ "operation": "clear", }) if err := h.sessionEventRepo.Create(c.Request.Context(), clearEvent); err != nil { logger.Error("Failed to record signing config event", zap.Error(err)) } c.JSON(http.StatusOK, gin.H{ "message": "signing parties cleared - all active parties will be used for signing", "username": username, }) } // GetSigningParties handles getting current signing parties configuration // GET /accounts/by-username/:username/signing-config func (h *AccountHTTPHandler) GetSigningParties(c *gin.Context) { username := c.Param("username") if username == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "username is required"}) return } // Get account by username accountOutput, err := h.getAccountUC.Execute(c.Request.Context(), ports.GetAccountInput{ Username: &username, }) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "account not found for username: " + username}) return } if accountOutput.Account.HasSigningPartiesConfig() { c.JSON(http.StatusOK, gin.H{ "configured": true, "username": username, "signing_parties": accountOutput.Account.GetSigningParties(), "threshold_t": accountOutput.Account.ThresholdT, }) } else { // Get all active parties var activeParties []string for _, share := range accountOutput.Shares { if share.IsActive { activeParties = append(activeParties, share.PartyID) } } c.JSON(http.StatusOK, gin.H{ "configured": false, "username": username, "message": "no signing parties configured - all active parties will be used", "active_parties": activeParties, "threshold_t": accountOutput.Account.ThresholdT, }) } }