diff --git a/internal/const.go b/internal/const.go index b451bfb4..e01f625e 100644 --- a/internal/const.go +++ b/internal/const.go @@ -176,3 +176,6 @@ const MetaforoPollNotExistsPrompt = "Poll not exists" const ( ERRCODE_GetMetaforoDataError = -1001 ) + +// Claim function is deployed at 2025.8.11, and the end date is 6 months later, which is 2026.1.11 00:00:00 UTC+8 +var ClaimSeeAssetEndDate = time.Date(2026, 1, 10, 16, 0, 0, 0, time.UTC) diff --git a/internal/model/proposal.go b/internal/model/proposal.go index 7514127a..4b6179ff 100644 --- a/internal/model/proposal.go +++ b/internal/model/proposal.go @@ -68,6 +68,7 @@ var ProposalStateIdNameMapping = map[string]ProposalState{ "execution_failed": ProposalStateExecutionFailed, "vetoed": ProposalStateVetoed, "deleted_from_metaforo": ProposalStateDeletedFromMetaforo, + "metaforo_error": ProposalStateUncategorizedMetaforoError, } var ProposalStateName = []string{ @@ -83,6 +84,8 @@ var ProposalStateName = []string{ "executed", "execution_failed", "vetoed", + "deleted_from_metaforo", + "metaforo_error", } // MetaforoUser saves user mapping between OS and metaforo diff --git a/internal/model/user_asset_record.go b/internal/model/user_asset_record.go index 88b3bd9c..e30fc58d 100644 --- a/internal/model/user_asset_record.go +++ b/internal/model/user_asset_record.go @@ -24,6 +24,9 @@ type UserAssetRecord struct { UpdatedAt time.Time `json:"-"` CreateTs int64 `json:"create_ts" gorm:"index"` UpdateTs int64 `json:"update_ts" gorm:"index"` + + // Claimed field is used for SEE claim requirements. + Claimed bool `json:"claimed" gorm:"default:false"` } type userAssetRecordModel struct{} @@ -70,6 +73,9 @@ func (*userAssetRecordModel) CreateOrUpdate(db *gorm.DB, userWallet string, asse AssetName: strings.ToUpper(assetName), DealtAmount: dealtAmount, ProcessingAmount: processingAmount, + + // Claimed field is added for see asset, for new created asset records, mark it as claimed + Claimed: true, }).Error } else { assetRecords[0].DealtAmount = assetRecords[0].DealtAmount.Add(dealtAmount) diff --git a/internal/model/user_asset_transfer_log.go b/internal/model/user_asset_transfer_log.go index 74984d1e..ec0c3cdb 100644 --- a/internal/model/user_asset_transfer_log.go +++ b/internal/model/user_asset_transfer_log.go @@ -83,18 +83,22 @@ func (*userAssetTransferLogModel) UpdateResult(db *gorm.DB, id uint, result stri } // ListPaginated returns paginated transfer records with optional filters -func (*userAssetTransferLogModel) ListPaginated(db *gorm.DB, page, size int, fromUser, toUser string) ([]*UserAssetTransferLog, int64, error) { +func (*userAssetTransferLogModel) ListPaginated(db *gorm.DB, page, size int, fromUser, toUser, myWallet string) ([]*UserAssetTransferLog, int64, error) { var logs []*UserAssetTransferLog var total int64 query := db.Model(&UserAssetTransferLog{}) - if fromUser != "" { - query = query.Where("from_user = ?", common.FormatUserWallet(fromUser)) - } + if myWallet != "" { + query = query.Where("from_user = ? OR to_user = ?", common.FormatUserWallet(myWallet), common.FormatUserWallet(myWallet)) + } else { + if fromUser != "" { + query = query.Where("from_user = ?", common.FormatUserWallet(fromUser)) + } - if toUser != "" { - query = query.Where("to_user = ?", common.FormatUserWallet(toUser)) + if toUser != "" { + query = query.Where("to_user = ?", common.FormatUserWallet(toUser)) + } } // Count total records diff --git a/internal/sdk/spp_client.go b/internal/sdk/spp_client.go index 9c46f669..9713ef9c 100644 --- a/internal/sdk/spp_client.go +++ b/internal/sdk/spp_client.go @@ -25,7 +25,9 @@ type SeepassResponse struct { } `json:"scr"` See struct { - Amount string `json:"amount"` + Amount string `json:"amount"` + Claimed bool `json:"claimed"` + AmountCanBeClaimed string `json:"amount_can_be_claimed"` } `json:"see"` Level struct { diff --git a/internal_inject/asset_records/asset_records.controller.go b/internal_inject/asset_records/asset_records.controller.go index f70de88a..a6ce3227 100644 --- a/internal_inject/asset_records/asset_records.controller.go +++ b/internal_inject/asset_records/asset_records.controller.go @@ -5,11 +5,13 @@ import ( "fmt" "net/http" "strconv" + "time" "github.com/facebookgo/inject" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" "github.com/theseed-labs/os-backend/global_object" + "github.com/theseed-labs/os-backend/internal" "github.com/theseed-labs/os-backend/internal/api" "github.com/theseed-labs/os-backend/internal/common" "github.com/theseed-labs/os-backend/internal/config" @@ -49,11 +51,13 @@ func Register(fatherGroup *gin.RouterGroup) { } // No auth endpoints - assetRecordsGroup.GET("/", assetRecords.List) - assetRecordsGroup.GET("/:id", assetRecords.Detail) + assetRecordsGroup.GET("/list", assetRecords.List) + assetRecordsGroup.GET("/show/:id", assetRecords.Detail) // Auth required endpoints - assetRecordsAuthGroup.POST("/", assetRecords.Create) + assetRecordsAuthGroup.GET("/my", assetRecords.MyList) + assetRecordsAuthGroup.POST("/new", assetRecords.Create) + assetRecordsAuthGroup.POST("/claim_see", assetRecords.ClaimSee) } // Create creates a new asset transfer @@ -105,6 +109,55 @@ func (c *AssetRecordsController) Create(ctx *gin.Context) { })) } +func (c *AssetRecordsController) MyList(ctx *gin.Context) { + user := api.ForContextOnlyUser(ctx) + + var queryParams TransferListQueryParams + if err := ctx.ShouldBindQuery(&queryParams); err != nil { + sdk.LogUserSideError(ctx, err) + ctx.JSON(http.StatusBadRequest, api.BadRequest(err)) + return + } + + // Validate pagination parameters + if queryParams.Page <= 0 { + queryParams.Page = DefaultPageNumber + } + if queryParams.Size <= 0 || queryParams.Size > MaxPageSize { + queryParams.Size = DefaultPageSize + } + + transferLogs, total, err := c.Service.ListTransfers(queryParams.Page, queryParams.Size, "", "", user.Wallet) + if err != nil { + log.Error().Msgf("Error listing transfers: %+v", err) + sdk.LogServerErrorToSentry(ctx, err) + ctx.JSON(http.StatusInternalServerError, api.ServerError(err)) + return + } + + // Convert to response format + response := make([]*TransferResponse, len(transferLogs)) + for i, transferLog := range transferLogs { + response[i] = &TransferResponse{ + ID: transferLog.ID, + FromUser: transferLog.FromUser, + ToUser: transferLog.ToUser, + AssetName: transferLog.AssetName, + Amount: transferLog.Amount, + TransactionTs: transferLog.TransactionTs, + Result: transferLog.Result, + Comment: transferLog.Comment, + } + } + + ctx.JSON(http.StatusOK, api.Success(api.ListReplyData{ + Page: queryParams.Page, + Size: queryParams.Size, + Total: total, + Rows: response, + })) +} + // List returns paginated asset transfer records with optional query filters func (c *AssetRecordsController) List(ctx *gin.Context) { var queryParams TransferListQueryParams @@ -122,7 +175,7 @@ func (c *AssetRecordsController) List(ctx *gin.Context) { queryParams.Size = DefaultPageSize } - transferLogs, total, err := c.Service.ListTransfers(queryParams.Page, queryParams.Size, queryParams.FromUser, queryParams.ToUser) + transferLogs, total, err := c.Service.ListTransfers(queryParams.Page, queryParams.Size, queryParams.FromUser, queryParams.ToUser, "") if err != nil { log.Error().Msgf("Error listing transfers: %+v", err) sdk.LogServerErrorToSentry(ctx, err) @@ -189,3 +242,29 @@ func (c *AssetRecordsController) Detail(ctx *gin.Context) { ctx.JSON(http.StatusOK, api.Success(response)) } + +// ClaimSee allows users to claim their asset records from indexer +func (c *AssetRecordsController) ClaimSee(ctx *gin.Context) { + if time.Now().After(internal.ClaimSeeAssetEndDate) { + err := errors.New("claim see asset end") + sdk.LogUserSideError(ctx, err) + ctx.JSON(http.StatusBadRequest, api.BadRequest(err)) + return + } + + user := api.ForContextOnlyUser(ctx) + statusCode, err := c.Service.ClaimUserSeeAssets(user.Wallet) + switch statusCode { + case http.StatusOK: + ctx.JSON(http.StatusOK, api.Success("see claimed successfully")) + return + case http.StatusBadRequest: + sdk.LogUserSideError(ctx, err) + ctx.JSON(http.StatusBadRequest, api.BadRequest(err)) + return + default: + sdk.LogServerErrorToSentry(ctx, err) + ctx.JSON(http.StatusInternalServerError, api.BadRequest(errors.New("asset record error, please contract admin"))) + return + } +} diff --git a/internal_inject/asset_records/asset_records.service.go b/internal_inject/asset_records/asset_records.service.go index f6fa84e4..106a1793 100644 --- a/internal_inject/asset_records/asset_records.service.go +++ b/internal_inject/asset_records/asset_records.service.go @@ -3,10 +3,13 @@ package asset_records_inject import ( "errors" "fmt" + "net/http" "strings" "github.com/rs/zerolog/log" "github.com/shopspring/decimal" + "github.com/theseed-labs/os-backend/internal" + "github.com/theseed-labs/os-backend/internal/common" "github.com/theseed-labs/os-backend/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -124,11 +127,91 @@ func (s *AssetRecordsService) CreateTransfer(fromUser, toUser, assetName string, } // ListTransfers returns paginated asset transfer records with optional filters -func (s *AssetRecordsService) ListTransfers(page, size int, fromUser, toUser string) ([]*model.UserAssetTransferLog, int64, error) { - return model.UserAssetTransferLogModel.ListPaginated(s.db, page, size, fromUser, toUser) +func (s *AssetRecordsService) ListTransfers(page, size int, fromUser, toUser, myWallet string) ([]*model.UserAssetTransferLog, int64, error) { + return model.UserAssetTransferLogModel.ListPaginated(s.db, page, size, fromUser, toUser, myWallet) } // GetTransferByID returns a single transfer record by ID func (s *AssetRecordsService) GetTransferByID(id uint) (*model.UserAssetTransferLog, error) { return model.UserAssetTransferLogModel.GetByID(s.db, id) } + +// GetUserAssetRecords checks if user already has asset records for specified asset +func (s *AssetRecordsService) GetUserAssetRecords(userWallet string, assetName string) ([]*model.UserAssetRecord, error) { + formattedWallet := common.FormatUserWallet(userWallet) + + var records []*model.UserAssetRecord + err := s.db.Where("user_wallet = ? AND asset_name = ?", formattedWallet, assetName).Find(&records).Error + if err != nil { + return nil, err + } + + return records, nil +} + +// ClaimUserSeeAssets gets user asset info from indexer and creates asset records +// It will firstly check whether user has SEE asset to claim, and return error if no records found +// Then it will convert the processing amount of SEE into dealt amount, and the things is done +// There will be no processing amount for SEE in near future, so checking processing amount to determine claim state is available +func (s *AssetRecordsService) ClaimUserSeeAssets(userWallet string) (int, error) { + formattedWallet := common.FormatUserWallet(userWallet) + statusCode := http.StatusOK + + err := s.db.Transaction(func(tx *gorm.DB) error { + // Use pessimistic locking to prevent concurrent access issues and check record count + var existingRecords []model.UserAssetRecord + err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}). + Where("user_wallet = ? AND asset_name = ? AND claimed = false", formattedWallet, internal.AssetNameSEE). + Find(&existingRecords).Error + + if err != nil { + statusCode = http.StatusInternalServerError + return err + } + + if len(existingRecords) == 0 { + statusCode = http.StatusBadRequest + return errors.New("user has no see asset records to claim") + } + + if len(existingRecords) > 1 { + statusCode = http.StatusInternalServerError + return errors.New("user has multiple see asset records") + } + + existingRecord := existingRecords[0] + + log.Error().Msgf("TTT: processing see: %+v", existingRecord.ProcessingAmount.Cmp(decimal.Zero)) + + // Check if there's any processing amount to claim + if existingRecord.ProcessingAmount.Cmp(decimal.Zero) == 0 { + statusCode = http.StatusBadRequest + return errors.New("user has no see amount to claim") + } + + // Perform atomic update using the locked record's processing amount + // This ensures we use the exact amount that was locked, preventing race conditions + result := tx.Model(&model.UserAssetRecord{}). + Where("id = ? AND processing_amount >= ?", existingRecord.ID, existingRecord.ProcessingAmount). + Updates(map[string]interface{}{ + "processing_amount": gorm.Expr("processing_amount::decimal - ?", existingRecord.ProcessingAmount.String()), + "dealt_amount": gorm.Expr("dealt_amount::decimal + ?", existingRecord.ProcessingAmount.String()), + "claimed": true, + }) + + if result.Error != nil { + statusCode = http.StatusInternalServerError + return result.Error + } + + // Check if the update actually affected any rows + if result.RowsAffected == 0 { + statusCode = http.StatusInternalServerError + return errors.New("failed to claim assets: record may have been modified by another transaction") + } + + return nil + }) + + return statusCode, err +} diff --git a/internal_inject/asset_records/consts.go b/internal_inject/asset_records/consts.go index ee4efd7a..04e77927 100644 --- a/internal_inject/asset_records/consts.go +++ b/internal_inject/asset_records/consts.go @@ -9,11 +9,14 @@ const ( ErrTransactionNotFound = "transaction record not found" ErrCheckingBalance = "error checking from user balance" ErrCreatingTransfer = "error creating transfer log" - ErrUpdatingFromUser = "error updating from user processing amount" - ErrUpdatingToUser = "error updating to user processing amount" - ErrUpdatingResult = "error updating transfer result" - ErrCompletingFromUser = "error completing from user transaction" - ErrCompletingToUser = "error completing to user transaction" + ErrUpdatingFromUser = "error updating from user processing amount" + ErrUpdatingToUser = "error updating to user processing amount" + ErrUpdatingResult = "error updating transfer result" + ErrCompletingFromUser = "error completing from user transaction" + ErrCompletingToUser = "error completing to user transaction" + ErrAlreadyClaimed = "you have already claimed your assets" + ErrClaimingAssets = "error claiming user assets" + ErrGettingIndexerBalance = "error getting balance from indexer" ) // Status values @@ -28,9 +31,9 @@ const ( // Pagination defaults const ( - DefaultPageSize = 20 - MaxPageSize = 100 - DefaultPageNumber = 1 + DefaultPageSize = 20 + MaxPageSize = 100 + DefaultPageNumber = 1 ) // Field names @@ -50,4 +53,4 @@ const ( // Default values const ( DefaultTransferAssetName = "SEE" -) \ No newline at end of file +) diff --git a/internal_inject/user/user.controller.go b/internal_inject/user/user.controller.go index 8c19dfc9..2317696c 100644 --- a/internal_inject/user/user.controller.go +++ b/internal_inject/user/user.controller.go @@ -415,8 +415,12 @@ func (ctrl *UserController) Detail(ctx *gin.Context) { api.PrintStructAsJson(assetRecords, "assetRecords") if len(assetRecords) == 0 { seepassResp.See.Amount = "0" + seepassResp.See.Claimed = true + seepassResp.See.AmountCanBeClaimed = "0" } else { seepassResp.See.Amount = assetRecords[0].DealtAmount.String() + seepassResp.See.Claimed = assetRecords[0].Claimed + seepassResp.See.AmountCanBeClaimed = assetRecords[0].ProcessingAmount.String() } // populate deepseek key