package service import ( "context" "fmt" "time" "github.com/redis/go-redis/v9" ) // mintRateLimitScript atomically increments the current window counter and // assigns its TTL when the key is first created. var mintRateLimitScript = redis.NewScript(` local n = redis.call("INCR", KEYS[1]) if n == 1 then redis.call("EXPIRE", KEYS[1], ARGV[1]) end return {n, n <= tonumber(ARGV[2]) and 1 or 0} `) // MintRateLimiter limits successful mint attempts with a Redis fixed window. type MintRateLimiter struct { rdb *redis.Client limit int64 window time.Duration } // NewMintRateLimiter creates a Redis-backed mint rate limiter. func NewMintRateLimiter(rdb *redis.Client, limit int64, window time.Duration) *MintRateLimiter { return &MintRateLimiter{rdb: rdb, limit: limit, window: window} } // IncrAndCheck atomically increments the owner's counter and returns its new // value and whether it is within the configured limit. func (l *MintRateLimiter) IncrAndCheck(ctx context.Context, ownerUID int64, assetType string) (int64, bool, error) { if l == nil || l.rdb == nil { return 0, false, fmt.Errorf("redis client is not initialized") } windowSeconds := int64(l.window.Seconds()) result, err := mintRateLimitScript.Run(ctx, l.rdb, []string{l.key(ownerUID, assetType)}, windowSeconds, l.limit).Result() if err != nil { return 0, false, fmt.Errorf("redis EVAL mint rate limit: %w", err) } values, ok := result.([]interface{}) if !ok || len(values) != 2 { return 0, false, fmt.Errorf("redis EVAL returned unexpected shape: %v", result) } count, countOK := values[0].(int64) allowed, allowedOK := values[1].(int64) if !countOK || !allowedOK { return 0, false, fmt.Errorf("redis EVAL returned unexpected values: %v", result) } return count, allowed == 1, nil } func (l *MintRateLimiter) key(ownerUID int64, assetType string) string { return fmt.Sprintf("periph:mint:%s:%d:%s", assetType, ownerUID, time.Now().UTC().Format("20060102")) }