From c3a361b02a73103a6645c04791189faffd2dd21f Mon Sep 17 00:00:00 2001 From: "Hayim.Shaul@ibm.com" Date: Sun, 21 Jun 2026 12:13:23 +0000 Subject: [PATCH 01/14] limit rate Signed-off-by: Hayim.Shaul@ibm.com --- docs/security/selector_resource_limits.md | 234 +++++++++++++ token/sdk/network/selector.go | 19 +- token/services/selector/config/driver.go | 30 ++ .../selector/simple/inmemory/locker.go | 106 +++++- .../simple/inmemory/locker_quota_test.go | 327 ++++++++++++++++++ .../selector/simple/inmemory/ratelimiter.go | 153 ++++++++ .../simple/inmemory/ratelimiter_test.go | 265 ++++++++++++++ token/services/selector/simple/selector.go | 21 +- .../services/selector/testutils/testutils.go | 4 + 9 files changed, 1150 insertions(+), 9 deletions(-) create mode 100644 docs/security/selector_resource_limits.md create mode 100644 token/services/selector/simple/inmemory/locker_quota_test.go create mode 100644 token/services/selector/simple/inmemory/ratelimiter.go create mode 100644 token/services/selector/simple/inmemory/ratelimiter_test.go diff --git a/docs/security/selector_resource_limits.md b/docs/security/selector_resource_limits.md new file mode 100644 index 0000000000..ff368eaa2f --- /dev/null +++ b/docs/security/selector_resource_limits.md @@ -0,0 +1,234 @@ +# Selector Resource Limits + +## Overview + +The Token SDK selector service implements per-identity resource limits to prevent resource exhaustion attacks and ensure fair resource allocation. These limits are enforced at the service layer and apply regardless of the storage backend in use. + +## Security Controls + +### 1. Lock Quota + +**Purpose**: Prevents any single identity (wallet) from monopolizing lock resources by imposing a hard upper bound on the number of active locks. + +**Default**: 1000 locks per identity + +**Behavior**: +- Each identity can hold a maximum number of simultaneous token locks +- Requests exceeding this quota are immediately rejected with `ErrQuotaExceeded` +- The quota is decremented when locks are released (via `UnlockIDs` or `UnlockByTxID`) +- Quota tracking is per-identity, ensuring isolation between different wallets + +**Error Handling**: +- Selector does NOT retry when quota is exceeded +- Applications should handle `ErrQuotaExceeded` by either: + - Waiting and retrying later + - Releasing unused locks + - Splitting operations across multiple identities + +### 2. Rate Limiting + +**Purpose**: Prevents burst flooding by limiting the rate at which an identity can create new locks. + +**Default**: 10 requests/second with burst capacity of 20 + +**Algorithm**: Token bucket +- Allows burst traffic up to the burst capacity +- Refills at a steady rate (requests per second) +- Provides smooth rate limiting with predictable behavior + +**Behavior**: +- Each identity has an independent rate limit bucket +- Requests exceeding the rate limit are immediately rejected with `ErrRateLimitExceeded` +- Rate limit state is maintained in memory and resets on service restart +- Empty identity strings bypass rate limiting (for backward compatibility) + +**Error Handling**: +- Selector does NOT retry when rate limit is exceeded +- Applications should implement exponential backoff or request throttling + +## Configuration + +### YAML Configuration + +Add to your configuration file under `token.selector`: + +```yaml +token: + selector: + # Maximum locks any single identity can hold simultaneously + # Set to 0 to disable quota enforcement + maxLocksPerIdentity: 1000 + + # Lock creation requests per second per identity + # Set to 0 to disable rate limiting + rateLimit: 10.0 + + # Burst capacity for rate limiter + # Should be >= rateLimit for smooth operation + rateLimitBurst: 20.0 +``` + +### Programmatic Configuration + +```go +import ( + "github.com/hyperledger-labs/fabric-token-sdk/token/services/selector/simple/inmemory" + "github.com/hyperledger-labs/fabric-token-sdk/token/sdk/network" +) + +// Create custom locker configuration +lockerConfig := inmemory.LockerConfig{ + MaxLocksPerIdentity: 500, // Lower quota for stricter control + RateLimit: 5.0, // 5 requests per second + RateLimitBurst: 10.0, // Allow bursts up to 10 +} + +// Create locker provider with custom config +lockerProvider := network.NewLockerProviderWithConfig( + ttxStoreServiceManager, + sleepTimeout, + validTxEvictionTimeout, + lockerConfig, +) +``` + +### Disabling Limits + +To disable a specific limit, set its value to 0: + +```yaml +token: + selector: + maxLocksPerIdentity: 0 # Unlimited locks + rateLimit: 0 # No rate limiting +``` + +## Monitoring + +### Metrics + +The following behaviors can be monitored through application logs: + +- **Quota Exceeded**: Log entries with "quota exceeded for identity" +- **Rate Limit Exceeded**: Log entries with "rate limit exceeded for identity" +- **Lock Count**: Current number of locks per identity (via debug logs) + +### Recommended Alerts + +1. **High Quota Usage**: Alert when an identity consistently approaches the quota limit +2. **Frequent Rate Limiting**: Alert when rate limit errors exceed a threshold +3. **Quota Exhaustion**: Alert when quota errors prevent legitimate operations + +## Best Practices + +### For Application Developers + +1. **Handle Errors Gracefully**: + ```go + _, err := selector.Select(ctx, ownerFilter, amount, tokenType) + if errors.HasType(err, inmemory.ErrQuotaExceeded) { + // Quota exceeded - wait or release locks + return handleQuotaExceeded(err) + } + if errors.HasType(err, inmemory.ErrRateLimitExceeded) { + // Rate limited - implement backoff + return handleRateLimitExceeded(err) + } + ``` + +2. **Release Locks Promptly**: Always unlock tokens when transactions fail or complete + +3. **Batch Operations**: Group related operations to minimize lock requests + +4. **Monitor Usage**: Track quota and rate limit errors in production + +### For Operators + +1. **Tune Limits**: Adjust based on observed usage patterns and system capacity + +2. **Set Conservative Defaults**: Start with lower limits and increase as needed + +3. **Monitor System Load**: Ensure limits don't cause legitimate operations to fail + +4. **Plan for Growth**: Review limits as transaction volume increases + +## Security Considerations + +### Attack Scenarios Mitigated + +1. **Resource Exhaustion**: Prevents a single malicious identity from locking all available tokens + +2. **Denial of Service**: Rate limiting prevents rapid-fire lock requests that could overwhelm the system + +3. **Lock Hoarding**: Quota limits prevent identities from accumulating excessive locks + +### Limitations + +1. **Sybil Attacks**: Limits are per-identity; attackers with multiple identities can still consume resources + +2. **Memory Usage**: Rate limiter state is kept in memory; many unique identities increase memory usage + +3. **Restart Behavior**: Rate limit state is lost on restart, allowing temporary burst after recovery + +## Backward Compatibility + +The implementation maintains backward compatibility: + +- The original `Lock()` method still works without identity tracking +- Locks created without identity bypass quota and rate limiting +- Existing code continues to function without modification +- New code should use `LockWithIdentity()` for security benefits + +## Error Types + +### ErrQuotaExceeded + +```go +var ErrQuotaExceeded = errors.New("lock quota exceeded for identity") +``` + +Returned when an identity attempts to acquire more locks than allowed by `maxLocksPerIdentity`. + +### ErrRateLimitExceeded + +```go +var ErrRateLimitExceeded = errors.New("rate limit exceeded") +``` + +Returned when an identity exceeds the configured rate limit for lock creation requests. + +## Testing + +### Unit Tests + +Comprehensive unit tests are provided in: +- `token/services/selector/simple/inmemory/ratelimiter_test.go` +- `token/services/selector/simple/inmemory/locker_quota_test.go` + +### Integration Testing + +Test quota and rate limiting in integration tests: + +```go +func TestQuotaEnforcement(t *testing.T) { + // Create selector with low quota for testing + config := inmemory.LockerConfig{ + MaxLocksPerIdentity: 5, + RateLimit: 0, + } + + // Attempt to exceed quota + for i := 0; i < 10; i++ { + _, err := selector.Select(ctx, ownerFilter, amount, tokenType) + if i >= 5 { + assert.ErrorIs(t, err, inmemory.ErrQuotaExceeded) + } + } +} +``` + +## References + +- [Token Selector Service Documentation](../services/selector.md) +- [Security Best Practices](../SECURITY.md) +- [Configuration Guide](../configuration.md) \ No newline at end of file diff --git a/token/sdk/network/selector.go b/token/sdk/network/selector.go index 5d0149420b..f54177cede 100644 --- a/token/sdk/network/selector.go +++ b/token/sdk/network/selector.go @@ -22,6 +22,7 @@ type LockerProvider struct { ttxStoreServiceManager db.StoreServiceManager[*ttxdb.StoreService] sleepTimeout time.Duration validTxEvictionTimeout time.Duration + lockerConfig inmemory.LockerConfig } // NewLockerProvider creates a new locker provider with the given configuration. @@ -34,6 +35,22 @@ func NewLockerProvider( ttxStoreServiceManager: ttxStoreServiceManager, sleepTimeout: sleepTimeout, validTxEvictionTimeout: validTxEvictionTimeout, + lockerConfig: inmemory.DefaultLockerConfig(), + } +} + +// NewLockerProviderWithConfig creates a new locker provider with custom locker configuration. +func NewLockerProviderWithConfig( + ttxStoreServiceManager db.StoreServiceManager[*ttxdb.StoreService], + sleepTimeout time.Duration, + validTxEvictionTimeout time.Duration, + lockerConfig inmemory.LockerConfig, +) *LockerProvider { + return &LockerProvider{ + ttxStoreServiceManager: ttxStoreServiceManager, + sleepTimeout: sleepTimeout, + validTxEvictionTimeout: validTxEvictionTimeout, + lockerConfig: lockerConfig, } } @@ -48,5 +65,5 @@ func (s *LockerProvider) New(network, channel, namespace string) (selector.Locke return nil, err } - return inmemory.NewLocker(db, s.sleepTimeout, s.validTxEvictionTimeout), nil + return inmemory.NewLockerWithConfig(db, s.sleepTimeout, s.validTxEvictionTimeout, s.lockerConfig), nil } diff --git a/token/services/selector/config/driver.go b/token/services/selector/config/driver.go index 5bff7d8553..bce48ca71a 100644 --- a/token/services/selector/config/driver.go +++ b/token/services/selector/config/driver.go @@ -22,6 +22,9 @@ const ( defaultFetcherCacheSize = 0 // 0 means use fetcher default defaultFetcherCacheRefresh = 0 // 0 means use fetcher default defaultFetcherCacheMaxQueries = 0 // 0 means use fetcher default + defaultMaxLocksPerIdentity = 1000 + defaultRateLimit = 10.0 // requests per second + defaultRateLimitBurst = 20.0 // burst capacity ) //go:generate counterfeiter -o mock/config_service.go -fake-name ConfigService . configService @@ -38,6 +41,9 @@ type Config struct { FetcherCacheSize int64 `yaml:"fetcherCacheSize,omitempty"` FetcherCacheRefresh time.Duration `yaml:"fetcherCacheRefresh,omitempty"` FetcherCacheMaxQueries int `yaml:"fetcherCacheMaxQueries,omitempty"` + MaxLocksPerIdentity int `yaml:"maxLocksPerIdentity,omitempty"` + RateLimit float64 `yaml:"rateLimit,omitempty"` + RateLimitBurst float64 `yaml:"rateLimitBurst,omitempty"` } // New returns a SelectorConfig with the values from the token.selector key @@ -101,6 +107,30 @@ func (c *Config) GetFetcherCacheRefresh() time.Duration { return c.FetcherCacheRefresh } +func (c *Config) GetMaxLocksPerIdentity() int { + if c.MaxLocksPerIdentity > 0 { + return c.MaxLocksPerIdentity + } + + return defaultMaxLocksPerIdentity +} + +func (c *Config) GetRateLimit() float64 { + if c.RateLimit > 0 { + return c.RateLimit + } + + return defaultRateLimit +} + +func (c *Config) GetRateLimitBurst() float64 { + if c.RateLimitBurst > 0 { + return c.RateLimitBurst + } + + return defaultRateLimitBurst +} + func (c *Config) GetFetcherCacheMaxQueries() int { // Return 0 if not set, which will trigger use of fetcher default return c.FetcherCacheMaxQueries diff --git a/token/services/selector/simple/inmemory/locker.go b/token/services/selector/simple/inmemory/locker.go index 3b929f92a5..34334a9099 100644 --- a/token/services/selector/simple/inmemory/locker.go +++ b/token/services/selector/simple/inmemory/locker.go @@ -39,35 +39,68 @@ type TXStatusProvider interface { type lockEntry struct { TxID string + Identity string Created time.Time LastAccess time.Time } func (l lockEntry) String() string { - return fmt.Sprintf("[[%s] since [%s], last access [%s]]", l.TxID, l.Created, l.LastAccess) + return fmt.Sprintf("[[%s][%s] since [%s], last access [%s]]", l.TxID, l.Identity, l.Created, l.LastAccess) } type locker struct { ttxdb TXStatusProvider lock *sync.RWMutex locked map[token2.ID]*lockEntry + identityLockCount map[string]int // tracks number of locks per identity sleepTimeout time.Duration validTxEvictionTimeout time.Duration cancel context.CancelFunc scanDone chan struct{} stopOnce sync.Once + rateLimiter *RateLimiter + maxLocksPerIdentity int +} + +// LockerConfig holds configuration for the locker +type LockerConfig struct { + MaxLocksPerIdentity int // Maximum locks any identity can hold (0 = unlimited) + RateLimit float64 // Lock requests per second per identity (0 = unlimited) + RateLimitBurst float64 // Burst capacity for rate limiter +} + +// DefaultLockerConfig returns sensible defaults +func DefaultLockerConfig() LockerConfig { + return LockerConfig{ + MaxLocksPerIdentity: 1000, + RateLimit: 10.0, + RateLimitBurst: 20.0, + } } func NewLocker(ttxdb TXStatusProvider, timeout time.Duration, validTxEvictionTimeout time.Duration) simple.Locker { + return NewLockerWithConfig(ttxdb, timeout, validTxEvictionTimeout, DefaultLockerConfig()) +} + +func NewLockerWithConfig(ttxdb TXStatusProvider, timeout time.Duration, validTxEvictionTimeout time.Duration, config LockerConfig) simple.Locker { ctx, cancel := context.WithCancel(context.Background()) + + var rateLimiter *RateLimiter + if config.RateLimit > 0 { + rateLimiter = NewRateLimiter(config.RateLimit, config.RateLimitBurst) + } + r := &locker{ ttxdb: ttxdb, sleepTimeout: timeout, lock: &sync.RWMutex{}, locked: map[token2.ID]*lockEntry{}, + identityLockCount: map[string]int{}, validTxEvictionTimeout: validTxEvictionTimeout, cancel: cancel, scanDone: make(chan struct{}), + rateLimiter: rateLimiter, + maxLocksPerIdentity: config.MaxLocksPerIdentity, } r.start(ctx) @@ -92,8 +125,21 @@ func (d *locker) Stop() error { } func (d *locker) Lock(ctx context.Context, id *token2.ID, txID string, reclaim bool) (string, error) { + return d.LockWithIdentity(ctx, id, txID, "", reclaim) +} + +func (d *locker) LockWithIdentity(ctx context.Context, id *token2.ID, txID string, identity string, reclaim bool) (string, error) { k := *id + // Apply rate limiting if configured and identity provided + if d.rateLimiter != nil && identity != "" { + if err := d.rateLimiter.Allow(identity); err != nil { + logger.DebugfContext(ctx, "rate limit exceeded for identity [%s]: %v", identity, err) + + return "", errors.Wrapf(simple.ErrRateLimitExceeded, "identity %s", identity) + } + } + // check quickly if the token is locked d.lock.RLock() if _, ok := d.locked[k]; ok && !reclaim { @@ -114,7 +160,7 @@ func (d *locker) Lock(ctx context.Context, id *token2.ID, txID string, reclaim b if reclaim { // Second chance logger.DebugfContext(ctx, "[%s] already locked by [%s], try to reclaim...", id, e) - reclaimed, status := d.reclaim(ctx, id, e.TxID) + reclaimed, status := d.reclaim(ctx, id, e.TxID, e.Identity) if !reclaimed { logger.DebugfContext(ctx, "[%s] already locked by [%s], reclaim failed, tx status [%s]", id, e, ttxdb.TxStatusMessage[status]) if logger.IsEnabledFor(zapcore.DebugLevel) { @@ -133,9 +179,25 @@ func (d *locker) Lock(ctx context.Context, id *token2.ID, txID string, reclaim b return e.TxID, AlreadyLockedError } } - logger.DebugfContext(ctx, "locking [%s] for [%s]", id, txID) + + // Check quota if configured and identity provided + if d.maxLocksPerIdentity > 0 && identity != "" { + currentCount := d.identityLockCount[identity] + if currentCount >= d.maxLocksPerIdentity { + logger.DebugfContext(ctx, "quota exceeded for identity [%s]: %d/%d locks", identity, currentCount, d.maxLocksPerIdentity) + + return "", errors.Wrapf(simple.ErrQuotaExceeded, "identity %s has %d locks (max %d)", identity, currentCount, d.maxLocksPerIdentity) + } + } + + logger.DebugfContext(ctx, "locking [%s] for [%s] by identity [%s]", id, txID, identity) now := time.Now() - d.locked[k] = &lockEntry{TxID: txID, Created: now, LastAccess: now} + d.locked[k] = &lockEntry{TxID: txID, Identity: identity, Created: now, LastAccess: now} + + // Update identity lock count + if identity != "" { + d.identityLockCount[identity]++ + } return "", nil } @@ -153,12 +215,20 @@ func (d *locker) UnlockIDs(ctx context.Context, ids ...*token2.ID) []*token2.ID entry, ok := d.locked[k] if !ok { notFound = append(notFound, &k) - logger.Warnf("unlocking [%s] hold by no one, skipping [%s]", id, entry) + logger.Warnf("unlocking [%s] hold by no one, skipping", id) continue } logger.DebugfContext(ctx, "unlocking [%s] hold by [%s]", id, entry) delete(d.locked, k) + + // Decrement identity lock count + if entry.Identity != "" { + d.identityLockCount[entry.Identity]-- + if d.identityLockCount[entry.Identity] <= 0 { + delete(d.identityLockCount, entry.Identity) + } + } } return notFound @@ -173,6 +243,14 @@ func (d *locker) UnlockByTxID(ctx context.Context, txID string) { if entry.TxID == txID { logger.DebugfContext(ctx, "unlocking [%s] hold by [%s]", id, entry) delete(d.locked, id) + + // Decrement identity lock count + if entry.Identity != "" { + d.identityLockCount[entry.Identity]-- + if d.identityLockCount[entry.Identity] <= 0 { + delete(d.identityLockCount, entry.Identity) + } + } } } } @@ -186,7 +264,7 @@ func (d *locker) IsLocked(id *token2.ID) bool { return ok } -func (d *locker) reclaim(ctx context.Context, id *token2.ID, txID string) (bool, int) { +func (d *locker) reclaim(ctx context.Context, id *token2.ID, txID string, identity string) (bool, int) { status, _, err := d.ttxdb.GetStatus(ctx, txID) if err != nil { return false, status @@ -195,6 +273,14 @@ func (d *locker) reclaim(ctx context.Context, id *token2.ID, txID string) (bool, case ttxdb.Deleted: delete(d.locked, *id) + // Decrement identity lock count + if identity != "" { + d.identityLockCount[identity]-- + if d.identityLockCount[identity] <= 0 { + delete(d.identityLockCount, identity) + } + } + return true, status default: return false, status @@ -258,6 +344,14 @@ func (d *locker) scan(ctx context.Context) { // reclaimed this token and re-locked it for a new transaction. if entry, ok := d.locked[s.id]; ok && entry.TxID == s.txID { delete(d.locked, s.id) + + // Decrement identity lock count + if entry.Identity != "" { + d.identityLockCount[entry.Identity]-- + if d.identityLockCount[entry.Identity] <= 0 { + delete(d.identityLockCount, entry.Identity) + } + } } } d.lock.Unlock() diff --git a/token/services/selector/simple/inmemory/locker_quota_test.go b/token/services/selector/simple/inmemory/locker_quota_test.go new file mode 100644 index 0000000000..1c4d56b142 --- /dev/null +++ b/token/services/selector/simple/inmemory/locker_quota_test.go @@ -0,0 +1,327 @@ +/* +Copyright IBM Corp. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package inmemory + +import ( + "context" + "testing" + + "github.com/hyperledger-labs/fabric-token-sdk/token/services/selector/simple" + token2 "github.com/hyperledger-labs/fabric-token-sdk/token/token" + "github.com/stretchr/testify/require" +) + +func TestLocker_QuotaEnforcement(t *testing.T) { + t.Run("enforces per-identity quota", func(t *testing.T) { + config := LockerConfig{ + MaxLocksPerIdentity: 5, + RateLimit: 0, // disable rate limiting for this test + } + locker := NewLockerWithConfig(newMockTXStatusProvider(), 0, 0, config).(*locker) + defer func() { _ = locker.Stop() }() + + ctx := context.Background() + identity := "wallet1" + txID := "tx1" + + // Should allow up to quota + for i := range 5 { + tokenID := &token2.ID{TxId: "token", Index: uint64(i)} + _, err := locker.LockWithIdentity(ctx, tokenID, txID, identity, false) + require.NoError(t, err, "lock %d should succeed", i) + } + + // Next lock should fail due to quota + tokenID := &token2.ID{TxId: "token", Index: 5} + _, err := locker.LockWithIdentity(ctx, tokenID, txID, identity, false) + require.ErrorIs(t, err, simple.ErrQuotaExceeded) + }) + + t.Run("quota is per-identity", func(t *testing.T) { + config := LockerConfig{ + MaxLocksPerIdentity: 3, + RateLimit: 0, + } + locker := NewLockerWithConfig(newMockTXStatusProvider(), 0, 0, config).(*locker) + defer func() { _ = locker.Stop() }() + + ctx := context.Background() + identity1 := "wallet1" + identity2 := "wallet2" + + // Lock 3 tokens for identity1 + for i := range 3 { + tokenID := &token2.ID{TxId: "token1", Index: uint64(i)} + _, err := locker.LockWithIdentity(ctx, tokenID, "tx1", identity1, false) + require.NoError(t, err) + } + + // identity1 should be at quota + tokenID := &token2.ID{TxId: "token1", Index: 3} + _, err := locker.LockWithIdentity(ctx, tokenID, "tx1", identity1, false) + require.ErrorIs(t, err, simple.ErrQuotaExceeded) + + // identity2 should still have full quota + for i := range 3 { + tokenID := &token2.ID{TxId: "token2", Index: uint64(i)} + _, err := locker.LockWithIdentity(ctx, tokenID, "tx2", identity2, false) + require.NoError(t, err, "identity2 lock %d should succeed", i) + } + }) + + t.Run("quota decreases on unlock", func(t *testing.T) { + config := LockerConfig{ + MaxLocksPerIdentity: 3, + RateLimit: 0, + } + locker := NewLockerWithConfig(newMockTXStatusProvider(), 0, 0, config).(*locker) + defer func() { _ = locker.Stop() }() + + ctx := context.Background() + identity := "wallet1" + + // Lock 3 tokens + var tokenIDs []*token2.ID + for i := range 3 { + tokenID := &token2.ID{TxId: "token", Index: uint64(i)} + tokenIDs = append(tokenIDs, tokenID) + _, err := locker.LockWithIdentity(ctx, tokenID, "tx1", identity, false) + require.NoError(t, err) + } + + // At quota + tokenID := &token2.ID{TxId: "token", Index: 3} + _, err := locker.LockWithIdentity(ctx, tokenID, "tx1", identity, false) + require.ErrorIs(t, err, simple.ErrQuotaExceeded) + + // Unlock 2 tokens + locker.UnlockIDs(ctx, tokenIDs[0], tokenIDs[1]) + + // Should now be able to lock 2 more + for i := 3; i < 5; i++ { + tokenID := &token2.ID{TxId: "token", Index: uint64(i)} + _, err := locker.LockWithIdentity(ctx, tokenID, "tx1", identity, false) + require.NoError(t, err, "lock after unlock %d should succeed", i) + } + }) + + t.Run("quota decreases on unlock by txID", func(t *testing.T) { + config := LockerConfig{ + MaxLocksPerIdentity: 3, + RateLimit: 0, + } + locker := NewLockerWithConfig(newMockTXStatusProvider(), 0, 0, config).(*locker) + defer func() { _ = locker.Stop() }() + + ctx := context.Background() + identity := "wallet1" + txID := "tx1" + + // Lock 3 tokens + for i := range 3 { + tokenID := &token2.ID{TxId: "token", Index: uint64(i)} + _, err := locker.LockWithIdentity(ctx, tokenID, txID, identity, false) + require.NoError(t, err) + } + + // At quota + tokenID := &token2.ID{TxId: "token", Index: 3} + _, err := locker.LockWithIdentity(ctx, tokenID, txID, identity, false) + require.ErrorIs(t, err, simple.ErrQuotaExceeded) + + // Unlock by txID + locker.UnlockByTxID(ctx, txID) + + // Should now have full quota again + for i := range 3 { + tokenID := &token2.ID{TxId: "token2", Index: uint64(i)} + _, err := locker.LockWithIdentity(ctx, tokenID, "tx2", identity, false) + require.NoError(t, err, "lock after unlock by txID %d should succeed", i) + } + }) + + t.Run("quota with empty identity is not enforced", func(t *testing.T) { + config := LockerConfig{ + MaxLocksPerIdentity: 3, + RateLimit: 0, + } + locker := NewLockerWithConfig(newMockTXStatusProvider(), 0, 0, config).(*locker) + defer func() { _ = locker.Stop() }() + + ctx := context.Background() + + // Should allow more than quota when identity is empty + for i := range 10 { + tokenID := &token2.ID{TxId: "token", Index: uint64(i)} + _, err := locker.LockWithIdentity(ctx, tokenID, "tx1", "", false) + require.NoError(t, err, "lock %d should succeed with empty identity", i) + } + }) + + t.Run("quota disabled when set to 0", func(t *testing.T) { + config := LockerConfig{ + MaxLocksPerIdentity: 0, // disabled + RateLimit: 0, + } + locker := NewLockerWithConfig(newMockTXStatusProvider(), 0, 0, config).(*locker) + defer func() { _ = locker.Stop() }() + + ctx := context.Background() + identity := "wallet1" + + // Should allow unlimited locks + for i := range 100 { + tokenID := &token2.ID{TxId: "token", Index: uint64(i)} + _, err := locker.LockWithIdentity(ctx, tokenID, "tx1", identity, false) + require.NoError(t, err, "lock %d should succeed with quota disabled", i) + } + }) +} + +func TestLocker_RateLimiting(t *testing.T) { + t.Run("enforces rate limit", func(t *testing.T) { + config := LockerConfig{ + MaxLocksPerIdentity: 0, // disable quota + RateLimit: 5.0, + RateLimitBurst: 5.0, + } + locker := NewLockerWithConfig(newMockTXStatusProvider(), 0, 0, config).(*locker) + defer func() { _ = locker.Stop() }() + + ctx := context.Background() + identity := "wallet1" + + // Should allow burst + for i := range 5 { + tokenID := &token2.ID{TxId: "token", Index: uint64(i)} + _, err := locker.LockWithIdentity(ctx, tokenID, "tx1", identity, false) + require.NoError(t, err, "lock %d should succeed within burst", i) + } + + // Next should be rate limited + tokenID := &token2.ID{TxId: "token", Index: 5} + _, err := locker.LockWithIdentity(ctx, tokenID, "tx1", identity, false) + require.ErrorIs(t, err, simple.ErrRateLimitExceeded) + }) + + t.Run("rate limit is per-identity", func(t *testing.T) { + config := LockerConfig{ + MaxLocksPerIdentity: 0, + RateLimit: 3.0, + RateLimitBurst: 3.0, + } + locker := NewLockerWithConfig(newMockTXStatusProvider(), 0, 0, config).(*locker) + defer func() { _ = locker.Stop() }() + + ctx := context.Background() + identity1 := "wallet1" + identity2 := "wallet2" + + // Exhaust identity1's rate limit + for i := range 3 { + tokenID := &token2.ID{TxId: "token1", Index: uint64(i)} + _, err := locker.LockWithIdentity(ctx, tokenID, "tx1", identity1, false) + require.NoError(t, err) + } + + // identity1 should be rate limited + tokenID := &token2.ID{TxId: "token1", Index: 3} + _, err := locker.LockWithIdentity(ctx, tokenID, "tx1", identity1, false) + require.ErrorIs(t, err, simple.ErrRateLimitExceeded) + + // identity2 should still have full rate limit + for i := range 3 { + tokenID := &token2.ID{TxId: "token2", Index: uint64(i)} + _, err := locker.LockWithIdentity(ctx, tokenID, "tx2", identity2, false) + require.NoError(t, err, "identity2 lock %d should succeed", i) + } + }) + + t.Run("rate limit with empty identity is not enforced", func(t *testing.T) { + config := LockerConfig{ + MaxLocksPerIdentity: 0, + RateLimit: 3.0, + RateLimitBurst: 3.0, + } + locker := NewLockerWithConfig(newMockTXStatusProvider(), 0, 0, config).(*locker) + defer func() { _ = locker.Stop() }() + + ctx := context.Background() + + // Should allow more than rate limit when identity is empty + for i := range 10 { + tokenID := &token2.ID{TxId: "token", Index: uint64(i)} + _, err := locker.LockWithIdentity(ctx, tokenID, "tx1", "", false) + require.NoError(t, err, "lock %d should succeed with empty identity", i) + } + }) + + t.Run("rate limit disabled when set to 0", func(t *testing.T) { + config := LockerConfig{ + MaxLocksPerIdentity: 0, + RateLimit: 0, // disabled + } + locker := NewLockerWithConfig(newMockTXStatusProvider(), 0, 0, config).(*locker) + defer func() { _ = locker.Stop() }() + + ctx := context.Background() + identity := "wallet1" + + // Should allow unlimited locks + for i := range 100 { + tokenID := &token2.ID{TxId: "token", Index: uint64(i)} + _, err := locker.LockWithIdentity(ctx, tokenID, "tx1", identity, false) + require.NoError(t, err, "lock %d should succeed with rate limit disabled", i) + } + }) +} + +func TestLocker_QuotaAndRateLimitCombined(t *testing.T) { + t.Run("both quota and rate limit enforced", func(t *testing.T) { + config := LockerConfig{ + MaxLocksPerIdentity: 10, + RateLimit: 5.0, + RateLimitBurst: 5.0, + } + locker := NewLockerWithConfig(newMockTXStatusProvider(), 0, 0, config).(*locker) + defer func() { _ = locker.Stop() }() + + ctx := context.Background() + identity := "wallet1" + + // Should allow burst (5 locks) + for i := range 5 { + tokenID := &token2.ID{TxId: "token", Index: uint64(i)} + _, err := locker.LockWithIdentity(ctx, tokenID, "tx1", identity, false) + require.NoError(t, err, "lock %d should succeed", i) + } + + // Next should be rate limited (not quota, since we're at 5/10) + tokenID := &token2.ID{TxId: "token", Index: 5} + _, err := locker.LockWithIdentity(ctx, tokenID, "tx1", identity, false) + require.ErrorIs(t, err, simple.ErrRateLimitExceeded) + }) + + t.Run("backward compatibility with Lock method", func(t *testing.T) { + config := LockerConfig{ + MaxLocksPerIdentity: 5, + RateLimit: 10.0, + RateLimitBurst: 10.0, + } + locker := NewLockerWithConfig(newMockTXStatusProvider(), 0, 0, config).(*locker) + defer func() { _ = locker.Stop() }() + + ctx := context.Background() + + // Old Lock method should work without identity tracking + for i := range 20 { + tokenID := &token2.ID{TxId: "token", Index: uint64(i)} + _, err := locker.Lock(ctx, tokenID, "tx1", false) + require.NoError(t, err, "lock %d should succeed without identity", i) + } + }) +} diff --git a/token/services/selector/simple/inmemory/ratelimiter.go b/token/services/selector/simple/inmemory/ratelimiter.go new file mode 100644 index 0000000000..bbf2f94b11 --- /dev/null +++ b/token/services/selector/simple/inmemory/ratelimiter.go @@ -0,0 +1,153 @@ +/* +Copyright IBM Corp. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package inmemory + +import ( + "sync" + "time" + + "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" + "github.com/hyperledger-labs/fabric-token-sdk/token/services/selector/simple" +) + +// tokenBucket implements a token bucket rate limiter for a single identity +type tokenBucket struct { + tokens float64 + maxTokens float64 + refillRate float64 // tokens per second + lastRefillTime time.Time + mu sync.Mutex +} + +// newTokenBucket creates a new token bucket with the specified capacity and refill rate +func newTokenBucket(maxTokens float64, refillRate float64) *tokenBucket { + return &tokenBucket{ + tokens: maxTokens, + maxTokens: maxTokens, + refillRate: refillRate, + lastRefillTime: time.Now(), + } +} + +// allow checks if a request can proceed and consumes a token if so +func (tb *tokenBucket) allow() bool { + tb.mu.Lock() + defer tb.mu.Unlock() + + now := time.Now() + elapsed := now.Sub(tb.lastRefillTime).Seconds() + + // Refill tokens based on elapsed time + tb.tokens += elapsed * tb.refillRate + if tb.tokens > tb.maxTokens { + tb.tokens = tb.maxTokens + } + tb.lastRefillTime = now + + // Check if we have tokens available + if tb.tokens >= 1.0 { + tb.tokens -= 1.0 + + return true + } + + return false +} + +// RateLimiter manages rate limits for multiple identities +type RateLimiter struct { + buckets map[string]*tokenBucket + mu sync.RWMutex + maxTokens float64 // burst capacity + refillRate float64 // tokens per second +} + +// NewRateLimiter creates a new rate limiter with the specified parameters +// requestsPerSecond: average number of requests allowed per second per identity +// burstSize: maximum burst of requests allowed (should be >= requestsPerSecond) +func NewRateLimiter(requestsPerSecond float64, burstSize float64) *RateLimiter { + if burstSize < requestsPerSecond { + burstSize = requestsPerSecond + } + + return &RateLimiter{ + buckets: make(map[string]*tokenBucket), + maxTokens: burstSize, + refillRate: requestsPerSecond, + } +} + +// Allow checks if a request from the given identity should be allowed +// Returns true if allowed, false if rate limit exceeded +func (rl *RateLimiter) Allow(identity string) error { + if identity == "" { + return errors.New("identity cannot be empty") + } + + // Fast path: check if bucket exists + rl.mu.RLock() + bucket, exists := rl.buckets[identity] + rl.mu.RUnlock() + + // Create bucket if it doesn't exist + if !exists { + rl.mu.Lock() + // Double-check after acquiring write lock + bucket, exists = rl.buckets[identity] + if !exists { + bucket = newTokenBucket(rl.maxTokens, rl.refillRate) + rl.buckets[identity] = bucket + } + rl.mu.Unlock() + } + + // Check if request is allowed + if !bucket.allow() { + return simple.ErrRateLimitExceeded + } + + return nil +} + +// Reset removes the rate limit state for a specific identity +// Useful for testing or administrative operations +func (rl *RateLimiter) Reset(identity string) { + rl.mu.Lock() + defer rl.mu.Unlock() + delete(rl.buckets, identity) +} + +// ResetAll clears all rate limit state +func (rl *RateLimiter) ResetAll() { + rl.mu.Lock() + defer rl.mu.Unlock() + rl.buckets = make(map[string]*tokenBucket) +} + +// GetStats returns current statistics for an identity (for monitoring/debugging) +func (rl *RateLimiter) GetStats(identity string) (availableTokens float64, exists bool) { + rl.mu.RLock() + bucket, exists := rl.buckets[identity] + rl.mu.RUnlock() + + if !exists { + return 0, false + } + + bucket.mu.Lock() + defer bucket.mu.Unlock() + + // Simulate refill to get current token count + now := time.Now() + elapsed := now.Sub(bucket.lastRefillTime).Seconds() + tokens := bucket.tokens + elapsed*bucket.refillRate + if tokens > bucket.maxTokens { + tokens = bucket.maxTokens + } + + return tokens, true +} diff --git a/token/services/selector/simple/inmemory/ratelimiter_test.go b/token/services/selector/simple/inmemory/ratelimiter_test.go new file mode 100644 index 0000000000..abefcad40c --- /dev/null +++ b/token/services/selector/simple/inmemory/ratelimiter_test.go @@ -0,0 +1,265 @@ +/* +Copyright IBM Corp. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package inmemory + +import ( + "sync" + "testing" + "time" + + "github.com/hyperledger-labs/fabric-token-sdk/token/services/selector/simple" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRateLimiter_Allow(t *testing.T) { + t.Run("allows requests within rate limit", func(t *testing.T) { + rl := NewRateLimiter(10, 10) // 10 req/sec, burst of 10 + identity := "wallet1" + + // Should allow up to burst size immediately + for i := range 10 { + err := rl.Allow(identity) + require.NoError(t, err, "request %d should be allowed", i) + } + + // Next request should be rate limited + err := rl.Allow(identity) + require.ErrorIs(t, err, simple.ErrRateLimitExceeded) + }) + + t.Run("refills tokens over time", func(t *testing.T) { + rl := NewRateLimiter(10, 10) // 10 req/sec + identity := "wallet1" + + // Exhaust the bucket + for range 10 { + _ = rl.Allow(identity) + } + + // Should be rate limited + err := rl.Allow(identity) + require.ErrorIs(t, err, simple.ErrRateLimitExceeded) + + // Wait for refill (100ms = 1 token at 10 req/sec) + time.Sleep(150 * time.Millisecond) + + // Should allow one more request + err = rl.Allow(identity) + assert.NoError(t, err) + }) + + t.Run("isolates different identities", func(t *testing.T) { + rl := NewRateLimiter(5, 5) + identity1 := "wallet1" + identity2 := "wallet2" + + // Exhaust identity1's quota + for range 5 { + err := rl.Allow(identity1) + require.NoError(t, err) + } + + // identity1 should be rate limited + err := rl.Allow(identity1) + require.ErrorIs(t, err, simple.ErrRateLimitExceeded) + + // identity2 should still have full quota + for i := range 5 { + err := rl.Allow(identity2) + require.NoError(t, err, "identity2 request %d should be allowed", i) + } + }) + + t.Run("rejects empty identity", func(t *testing.T) { + rl := NewRateLimiter(10, 10) + err := rl.Allow("") + require.Error(t, err) + assert.Contains(t, err.Error(), "identity cannot be empty") + }) + + t.Run("handles concurrent requests", func(t *testing.T) { + rl := NewRateLimiter(100, 100) + identity := "wallet1" + numGoroutines := 10 + requestsPerGoroutine := 15 + + var wg sync.WaitGroup + var mu sync.Mutex + allowed := 0 + denied := 0 + + for range numGoroutines { + wg.Go(func() { + for range requestsPerGoroutine { + err := rl.Allow(identity) + mu.Lock() + if err == nil { + allowed++ + } else { + denied++ + } + mu.Unlock() + } + }) + } + + wg.Wait() + + // Should allow exactly burst size (100) + assert.Equal(t, 100, allowed, "should allow exactly burst size") + assert.Equal(t, 50, denied, "remaining requests should be denied") + }) +} + +func TestRateLimiter_Reset(t *testing.T) { + t.Run("resets specific identity", func(t *testing.T) { + rl := NewRateLimiter(5, 5) + identity := "wallet1" + + // Exhaust quota + for range 5 { + _ = rl.Allow(identity) + } + + // Should be rate limited + err := rl.Allow(identity) + require.ErrorIs(t, err, simple.ErrRateLimitExceeded) + + // Reset + rl.Reset(identity) + + // Should have full quota again + for i := range 5 { + err := rl.Allow(identity) + require.NoError(t, err, "request %d should be allowed after reset", i) + } + }) + + t.Run("resets all identities", func(t *testing.T) { + rl := NewRateLimiter(5, 5) + identity1 := "wallet1" + identity2 := "wallet2" + + // Exhaust both quotas + for range 5 { + _ = rl.Allow(identity1) + _ = rl.Allow(identity2) + } + + // Both should be rate limited + require.ErrorIs(t, rl.Allow(identity1), simple.ErrRateLimitExceeded) + require.ErrorIs(t, rl.Allow(identity2), simple.ErrRateLimitExceeded) + + // Reset all + rl.ResetAll() + + // Both should have full quota + assert.NoError(t, rl.Allow(identity1)) + assert.NoError(t, rl.Allow(identity2)) + }) +} + +func TestRateLimiter_GetStats(t *testing.T) { + t.Run("returns stats for existing identity", func(t *testing.T) { + rl := NewRateLimiter(10, 10) + identity := "wallet1" + + // Make some requests + for range 3 { + _ = rl.Allow(identity) + } + + tokens, exists := rl.GetStats(identity) + assert.True(t, exists) + assert.InDelta(t, 7.0, tokens, 0.1, "should have ~7 tokens remaining") + }) + + t.Run("returns false for non-existent identity", func(t *testing.T) { + rl := NewRateLimiter(10, 10) + _, exists := rl.GetStats("nonexistent") + assert.False(t, exists) + }) + + t.Run("shows refill over time", func(t *testing.T) { + rl := NewRateLimiter(10, 10) + identity := "wallet1" + + // Exhaust bucket + for range 10 { + _ = rl.Allow(identity) + } + + tokens, _ := rl.GetStats(identity) + assert.InDelta(t, 0.0, tokens, 0.1) + + // Wait for refill + time.Sleep(500 * time.Millisecond) + + tokens, _ = rl.GetStats(identity) + assert.Greater(t, tokens, 4.0, "should have refilled ~5 tokens") + }) +} + +func TestTokenBucket_Allow(t *testing.T) { + t.Run("allows burst up to max tokens", func(t *testing.T) { + tb := newTokenBucket(5, 1) // 5 tokens, 1 per second + + // Should allow 5 requests immediately + for i := range 5 { + assert.True(t, tb.allow(), "request %d should be allowed", i) + } + + // 6th request should fail + assert.False(t, tb.allow()) + }) + + t.Run("refills at specified rate", func(t *testing.T) { + tb := newTokenBucket(10, 10) // 10 tokens per second + + // Exhaust bucket + for range 10 { + tb.allow() + } + + assert.False(t, tb.allow()) + + // Wait for 1 token to refill (100ms at 10/sec) + time.Sleep(150 * time.Millisecond) + + assert.True(t, tb.allow(), "should allow after refill") + }) + + t.Run("caps tokens at max", func(t *testing.T) { + tb := newTokenBucket(5, 10) + + // Wait for potential overflow + time.Sleep(1 * time.Second) + + // Should still only allow max tokens + allowed := 0 + for range 10 { + if tb.allow() { + allowed++ + } + } + + assert.Equal(t, 5, allowed, "should cap at max tokens") + }) +} + +func TestNewRateLimiter_BurstValidation(t *testing.T) { + t.Run("adjusts burst to match rate if too small", func(t *testing.T) { + rl := NewRateLimiter(10, 5) // burst < rate + assert.InDelta(t, 10.0, rl.maxTokens, 0.01, "burst should be adjusted to match rate") + }) + + t.Run("keeps burst if larger than rate", func(t *testing.T) { + rl := NewRateLimiter(10, 20) + assert.InDelta(t, 20.0, rl.maxTokens, 0.01, "burst should remain as specified") + }) +} diff --git a/token/services/selector/simple/selector.go b/token/services/selector/simple/selector.go index c205c3b418..49e9972f22 100644 --- a/token/services/selector/simple/selector.go +++ b/token/services/selector/simple/selector.go @@ -16,6 +16,13 @@ import ( "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" ) +var ( + // ErrQuotaExceeded is returned when an identity exceeds their lock quota + ErrQuotaExceeded = errors.New("lock quota exceeded for identity") + // ErrRateLimitExceeded is returned when a caller exceeds their rate limit + ErrRateLimitExceeded = errors.New("rate limit exceeded") +) + type QueryService interface { UnspentTokensIterator(ctx context.Context) (*token.UnspentTokensIterator, error) UnspentTokensIteratorBy(ctx context.Context, id string, tokenType token2.Type) (driver.UnspentTokensIterator, error) @@ -24,6 +31,8 @@ type QueryService interface { type Locker interface { Lock(ctx context.Context, id *token2.ID, txID string, reclaim bool) (string, error) + // LockWithIdentity locks a token with identity tracking for quota and rate limiting + LockWithIdentity(ctx context.Context, id *token2.ID, txID string, identity string, reclaim bool) (string, error) // UnlockIDs unlocks the passed IDS. It returns the list of tokens that were not locked in the first place among // those passed. UnlockIDs(ctx context.Context, ids ...*token2.ID) []*token2.ID @@ -113,8 +122,16 @@ func (s *selector) selectByID(ctx context.Context, ownerFilter token.OwnerFilter return nil, nil, errors.Wrap(err, "failed to convert quantity") } - // lock the token - if _, lockErr := s.locker.Lock(ctx, &t.Id, s.txID, reclaim); lockErr != nil { + // lock the token with identity tracking + if _, lockErr := s.locker.LockWithIdentity(ctx, &t.Id, s.txID, id, reclaim); lockErr != nil { + // Check if this is a quota or rate limit error - these should not be retried + if errors.HasType(lockErr, ErrQuotaExceeded) || errors.HasType(lockErr, ErrRateLimitExceeded) { + s.locker.UnlockIDs(ctx, toBeSpent...) + s.locker.UnlockIDs(ctx, toBeCertified...) + + return nil, nil, lockErr + } + var addErr error potentialSumWithLocked, addErr = potentialSumWithLocked.Add(q) if addErr != nil { diff --git a/token/services/selector/testutils/testutils.go b/token/services/selector/testutils/testutils.go index 5efc8495b0..4df70baf36 100644 --- a/token/services/selector/testutils/testutils.go +++ b/token/services/selector/testutils/testutils.go @@ -173,6 +173,10 @@ func (n *NoLock) Lock(ctx context.Context, id *token2.ID, txID string, reclaim b return "", nil } +func (n *NoLock) LockWithIdentity(ctx context.Context, id *token2.ID, txID string, identity string, reclaim bool) (string, error) { + return "", nil +} + func (n *NoLock) UnlockIDs(ctx context.Context, ids ...*token2.ID) []*token2.ID { return ids } From d4fac548d50a6135d2a3114e4cb5c84e0bd2e1b6 Mon Sep 17 00:00:00 2001 From: "Hayim.Shaul@ibm.com" Date: Sun, 21 Jun 2026 12:20:50 +0000 Subject: [PATCH 02/14] fix broken link Signed-off-by: Hayim.Shaul@ibm.com --- docs/security/selector_resource_limits.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/security/selector_resource_limits.md b/docs/security/selector_resource_limits.md index ff368eaa2f..9d319674a2 100644 --- a/docs/security/selector_resource_limits.md +++ b/docs/security/selector_resource_limits.md @@ -230,5 +230,4 @@ func TestQuotaEnforcement(t *testing.T) { ## References - [Token Selector Service Documentation](../services/selector.md) -- [Security Best Practices](../SECURITY.md) - [Configuration Guide](../configuration.md) \ No newline at end of file From f9e1f99ee0cdd9966ae8b31e69f563fd49a1d469 Mon Sep 17 00:00:00 2001 From: "Hayim.Shaul@ibm.com" Date: Tue, 30 Jun 2026 09:42:05 +0000 Subject: [PATCH 03/14] add ratelimiter cleanup Signed-off-by: Hayim.Shaul@ibm.com --- .../selector/simple/inmemory/locker.go | 13 +- .../selector/simple/inmemory/ratelimiter.go | 127 +++++++++++++++--- .../simple/inmemory/ratelimiter_test.go | 91 +++++++++++-- 3 files changed, 195 insertions(+), 36 deletions(-) diff --git a/token/services/selector/simple/inmemory/locker.go b/token/services/selector/simple/inmemory/locker.go index 34334a9099..fc72bb94de 100644 --- a/token/services/selector/simple/inmemory/locker.go +++ b/token/services/selector/simple/inmemory/locker.go @@ -64,9 +64,10 @@ type locker struct { // LockerConfig holds configuration for the locker type LockerConfig struct { - MaxLocksPerIdentity int // Maximum locks any identity can hold (0 = unlimited) - RateLimit float64 // Lock requests per second per identity (0 = unlimited) - RateLimitBurst float64 // Burst capacity for rate limiter + MaxLocksPerIdentity int // Maximum locks any identity can hold (0 = unlimited) + RateLimit float64 // Lock requests per second per identity (0 = unlimited) + RateLimitBurst float64 // Burst capacity for rate limiter + RateLimitIdleTTL time.Duration // How long a rate-limit bucket may be idle before eviction (0 = no eviction) } // DefaultLockerConfig returns sensible defaults @@ -75,6 +76,7 @@ func DefaultLockerConfig() LockerConfig { MaxLocksPerIdentity: 1000, RateLimit: 10.0, RateLimitBurst: 20.0, + RateLimitIdleTTL: 10 * time.Minute, } } @@ -87,7 +89,7 @@ func NewLockerWithConfig(ttxdb TXStatusProvider, timeout time.Duration, validTxE var rateLimiter *RateLimiter if config.RateLimit > 0 { - rateLimiter = NewRateLimiter(config.RateLimit, config.RateLimitBurst) + rateLimiter = NewRateLimiter(config.RateLimit, config.RateLimitBurst, config.RateLimitIdleTTL, 0) } r := &locker{ @@ -119,6 +121,9 @@ func (d *locker) Stop() error { err = ErrTimeout logger.Warnf("scan goroutine did not stop within timeout") } + if d.rateLimiter != nil { + d.rateLimiter.Stop() + } }) return err diff --git a/token/services/selector/simple/inmemory/ratelimiter.go b/token/services/selector/simple/inmemory/ratelimiter.go index bbf2f94b11..52c44f223c 100644 --- a/token/services/selector/simple/inmemory/ratelimiter.go +++ b/token/services/selector/simple/inmemory/ratelimiter.go @@ -7,6 +7,7 @@ SPDX-License-Identifier: Apache-2.0 package inmemory import ( + "context" "sync" "time" @@ -20,16 +21,19 @@ type tokenBucket struct { maxTokens float64 refillRate float64 // tokens per second lastRefillTime time.Time + lastSeen time.Time // updated on every allow() call; used for idle eviction mu sync.Mutex } // newTokenBucket creates a new token bucket with the specified capacity and refill rate func newTokenBucket(maxTokens float64, refillRate float64) *tokenBucket { + now := time.Now() return &tokenBucket{ tokens: maxTokens, maxTokens: maxTokens, refillRate: refillRate, - lastRefillTime: time.Now(), + lastRefillTime: now, + lastSeen: now, } } @@ -47,6 +51,7 @@ func (tb *tokenBucket) allow() bool { tb.tokens = tb.maxTokens } tb.lastRefillTime = now + tb.lastSeen = now // Check if we have tokens available if tb.tokens >= 1.0 { @@ -58,31 +63,63 @@ func (tb *tokenBucket) allow() bool { return false } -// RateLimiter manages rate limits for multiple identities +// RateLimiter manages rate limits for multiple identities. +// Idle buckets (those not accessed for longer than idleTTL) are evicted +// by a background goroutine that runs every cleanupInterval. type RateLimiter struct { - buckets map[string]*tokenBucket - mu sync.RWMutex - maxTokens float64 // burst capacity - refillRate float64 // tokens per second + buckets map[string]*tokenBucket + mu sync.RWMutex + maxTokens float64 // burst capacity + refillRate float64 // tokens per second + idleTTL time.Duration + cleanupInterval time.Duration + cancel context.CancelFunc + cleanupDone chan struct{} } -// NewRateLimiter creates a new rate limiter with the specified parameters -// requestsPerSecond: average number of requests allowed per second per identity -// burstSize: maximum burst of requests allowed (should be >= requestsPerSecond) -func NewRateLimiter(requestsPerSecond float64, burstSize float64) *RateLimiter { +// NewRateLimiter creates a new rate limiter with the specified parameters. +// requestsPerSecond: average number of requests allowed per second per identity. +// burstSize: maximum burst of requests allowed (should be >= requestsPerSecond). +// idleTTL: how long a bucket may be idle before it is evicted (0 = no eviction). +// cleanupInterval: how often the eviction sweep runs (0 = defaults to idleTTL/2). +func NewRateLimiter(requestsPerSecond float64, burstSize float64, idleTTL time.Duration, cleanupInterval time.Duration) *RateLimiter { if burstSize < requestsPerSecond { burstSize = requestsPerSecond } + if idleTTL > 0 && cleanupInterval <= 0 { + cleanupInterval = idleTTL / 2 + } - return &RateLimiter{ - buckets: make(map[string]*tokenBucket), - maxTokens: burstSize, - refillRate: requestsPerSecond, + ctx, cancel := context.WithCancel(context.Background()) + + rl := &RateLimiter{ + buckets: make(map[string]*tokenBucket), + maxTokens: burstSize, + refillRate: requestsPerSecond, + idleTTL: idleTTL, + cleanupInterval: cleanupInterval, + cancel: cancel, + cleanupDone: make(chan struct{}), } + + if idleTTL > 0 { + go rl.sweepLoop(ctx) + } else { + // No sweeping needed; signal done immediately so Stop() returns fast. + close(rl.cleanupDone) + } + + return rl } -// Allow checks if a request from the given identity should be allowed -// Returns true if allowed, false if rate limit exceeded +// Stop stops the background cleanup goroutine and waits for it to exit. +func (rl *RateLimiter) Stop() { + rl.cancel() + <-rl.cleanupDone +} + +// Allow checks if a request from the given identity should be allowed. +// Returns nil if allowed, ErrRateLimitExceeded if the rate limit is exceeded. func (rl *RateLimiter) Allow(identity string) error { if identity == "" { return errors.New("identity cannot be empty") @@ -113,22 +150,22 @@ func (rl *RateLimiter) Allow(identity string) error { return nil } -// Reset removes the rate limit state for a specific identity -// Useful for testing or administrative operations +// Reset removes the rate limit state for a specific identity. +// Useful for testing or administrative operations. func (rl *RateLimiter) Reset(identity string) { rl.mu.Lock() defer rl.mu.Unlock() delete(rl.buckets, identity) } -// ResetAll clears all rate limit state +// ResetAll clears all rate limit state. func (rl *RateLimiter) ResetAll() { rl.mu.Lock() defer rl.mu.Unlock() rl.buckets = make(map[string]*tokenBucket) } -// GetStats returns current statistics for an identity (for monitoring/debugging) +// GetStats returns current statistics for an identity (for monitoring/debugging). func (rl *RateLimiter) GetStats(identity string) (availableTokens float64, exists bool) { rl.mu.RLock() bucket, exists := rl.buckets[identity] @@ -151,3 +188,53 @@ func (rl *RateLimiter) GetStats(identity string) (availableTokens float64, exist return tokens, true } + +// sweepLoop runs periodically and evicts buckets that have been idle for longer than idleTTL. +func (rl *RateLimiter) sweepLoop(ctx context.Context) { + defer close(rl.cleanupDone) + ticker := time.NewTicker(rl.cleanupInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + rl.evictIdle() + } + } +} + +// evictIdle removes all buckets whose lastSeen is older than idleTTL. +func (rl *RateLimiter) evictIdle() { + cutoff := time.Now().Add(-rl.idleTTL) + + // Collect candidates under read lock to minimise write-lock contention. + rl.mu.RLock() + var stale []string + for id, bucket := range rl.buckets { + bucket.mu.Lock() + if bucket.lastSeen.Before(cutoff) { + stale = append(stale, id) + } + bucket.mu.Unlock() + } + rl.mu.RUnlock() + + if len(stale) == 0 { + return + } + + rl.mu.Lock() + for _, id := range stale { + // Re-check under write lock: the bucket may have been accessed between + // the RLock scan and acquiring the write lock. + if b, ok := rl.buckets[id]; ok { + b.mu.Lock() + if b.lastSeen.Before(cutoff) { + delete(rl.buckets, id) + } + b.mu.Unlock() + } + } + rl.mu.Unlock() +} diff --git a/token/services/selector/simple/inmemory/ratelimiter_test.go b/token/services/selector/simple/inmemory/ratelimiter_test.go index abefcad40c..44c6e8bc32 100644 --- a/token/services/selector/simple/inmemory/ratelimiter_test.go +++ b/token/services/selector/simple/inmemory/ratelimiter_test.go @@ -18,7 +18,7 @@ import ( func TestRateLimiter_Allow(t *testing.T) { t.Run("allows requests within rate limit", func(t *testing.T) { - rl := NewRateLimiter(10, 10) // 10 req/sec, burst of 10 + rl := NewRateLimiter(10, 10, 0, 0) // 10 req/sec, burst of 10 identity := "wallet1" // Should allow up to burst size immediately @@ -33,7 +33,7 @@ func TestRateLimiter_Allow(t *testing.T) { }) t.Run("refills tokens over time", func(t *testing.T) { - rl := NewRateLimiter(10, 10) // 10 req/sec + rl := NewRateLimiter(10, 10, 0, 0) // 10 req/sec identity := "wallet1" // Exhaust the bucket @@ -54,7 +54,7 @@ func TestRateLimiter_Allow(t *testing.T) { }) t.Run("isolates different identities", func(t *testing.T) { - rl := NewRateLimiter(5, 5) + rl := NewRateLimiter(5, 5, 0, 0) identity1 := "wallet1" identity2 := "wallet2" @@ -76,14 +76,14 @@ func TestRateLimiter_Allow(t *testing.T) { }) t.Run("rejects empty identity", func(t *testing.T) { - rl := NewRateLimiter(10, 10) + rl := NewRateLimiter(10, 10, 0, 0) err := rl.Allow("") require.Error(t, err) assert.Contains(t, err.Error(), "identity cannot be empty") }) t.Run("handles concurrent requests", func(t *testing.T) { - rl := NewRateLimiter(100, 100) + rl := NewRateLimiter(100, 100, 0, 0) identity := "wallet1" numGoroutines := 10 requestsPerGoroutine := 15 @@ -118,7 +118,7 @@ func TestRateLimiter_Allow(t *testing.T) { func TestRateLimiter_Reset(t *testing.T) { t.Run("resets specific identity", func(t *testing.T) { - rl := NewRateLimiter(5, 5) + rl := NewRateLimiter(5, 5, 0, 0) identity := "wallet1" // Exhaust quota @@ -141,7 +141,7 @@ func TestRateLimiter_Reset(t *testing.T) { }) t.Run("resets all identities", func(t *testing.T) { - rl := NewRateLimiter(5, 5) + rl := NewRateLimiter(5, 5, 0, 0) identity1 := "wallet1" identity2 := "wallet2" @@ -166,7 +166,7 @@ func TestRateLimiter_Reset(t *testing.T) { func TestRateLimiter_GetStats(t *testing.T) { t.Run("returns stats for existing identity", func(t *testing.T) { - rl := NewRateLimiter(10, 10) + rl := NewRateLimiter(10, 10, 0, 0) identity := "wallet1" // Make some requests @@ -180,13 +180,13 @@ func TestRateLimiter_GetStats(t *testing.T) { }) t.Run("returns false for non-existent identity", func(t *testing.T) { - rl := NewRateLimiter(10, 10) + rl := NewRateLimiter(10, 10, 0, 0) _, exists := rl.GetStats("nonexistent") assert.False(t, exists) }) t.Run("shows refill over time", func(t *testing.T) { - rl := NewRateLimiter(10, 10) + rl := NewRateLimiter(10, 10, 0, 0) identity := "wallet1" // Exhaust bucket @@ -254,12 +254,79 @@ func TestTokenBucket_Allow(t *testing.T) { func TestNewRateLimiter_BurstValidation(t *testing.T) { t.Run("adjusts burst to match rate if too small", func(t *testing.T) { - rl := NewRateLimiter(10, 5) // burst < rate + rl := NewRateLimiter(10, 5, 0, 0) // burst < rate assert.InDelta(t, 10.0, rl.maxTokens, 0.01, "burst should be adjusted to match rate") }) t.Run("keeps burst if larger than rate", func(t *testing.T) { - rl := NewRateLimiter(10, 20) + rl := NewRateLimiter(10, 20, 0, 0) assert.InDelta(t, 20.0, rl.maxTokens, 0.01, "burst should remain as specified") }) } + +func TestRateLimiter_IdleEviction(t *testing.T) { + t.Run("evicts idle bucket after TTL", func(t *testing.T) { + ttl := 100 * time.Millisecond + rl := NewRateLimiter(10, 10, ttl, 20*time.Millisecond) + defer rl.Stop() + + identity := "wallet1" + require.NoError(t, rl.Allow(identity)) + + // Bucket must exist immediately after use + _, exists := rl.GetStats(identity) + assert.True(t, exists) + + // Wait longer than TTL + one cleanup interval + time.Sleep(200 * time.Millisecond) + + // Bucket should have been evicted + _, exists = rl.GetStats(identity) + assert.False(t, exists, "idle bucket should be evicted after TTL") + }) + + t.Run("keeps active bucket alive", func(t *testing.T) { + ttl := 100 * time.Millisecond + rl := NewRateLimiter(100, 100, ttl, 20*time.Millisecond) + defer rl.Stop() + + identity := "wallet1" + + // Keep touching the bucket every 30ms for 180ms — never idle for 100ms + for range 6 { + require.NoError(t, rl.Allow(identity)) + time.Sleep(30 * time.Millisecond) + } + + _, exists := rl.GetStats(identity) + assert.True(t, exists, "active bucket should not be evicted") + }) + + t.Run("no eviction when idleTTL is zero", func(t *testing.T) { + rl := NewRateLimiter(10, 10, 0, 0) + defer rl.Stop() + + identity := "wallet1" + require.NoError(t, rl.Allow(identity)) + + time.Sleep(50 * time.Millisecond) + + _, exists := rl.GetStats(identity) + assert.True(t, exists, "bucket should persist when eviction is disabled") + }) + + t.Run("Stop waits for goroutine", func(t *testing.T) { + rl := NewRateLimiter(10, 10, 500*time.Millisecond, 100*time.Millisecond) + // Stop must return promptly and not block + done := make(chan struct{}) + go func() { + rl.Stop() + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Stop() did not return within timeout") + } + }) +} From 4802200427098fc014884cc7c483626d11b306fd Mon Sep 17 00:00:00 2001 From: "Hayim.Shaul@ibm.com" Date: Tue, 30 Jun 2026 11:05:25 +0000 Subject: [PATCH 04/14] added concurrency tests Signed-off-by: Hayim.Shaul@ibm.com --- .../simple/inmemory/ratelimiter_test.go | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/token/services/selector/simple/inmemory/ratelimiter_test.go b/token/services/selector/simple/inmemory/ratelimiter_test.go index 44c6e8bc32..e07cd7f34a 100644 --- a/token/services/selector/simple/inmemory/ratelimiter_test.go +++ b/token/services/selector/simple/inmemory/ratelimiter_test.go @@ -7,6 +7,7 @@ SPDX-License-Identifier: Apache-2.0 package inmemory import ( + "fmt" "sync" "testing" "time" @@ -252,6 +253,140 @@ func TestTokenBucket_Allow(t *testing.T) { }) } +func TestRateLimiter_Parallel(t *testing.T) { + // TestRateLimiter_Parallel verifies correct behaviour under high concurrency. + + t.Run("single identity: allowed count equals burst size", func(t *testing.T) { + // With burst=50 and many more goroutines than burst, exactly 50 requests + // must be allowed and the rest denied — no more, no less. + const burst = 50 + rl := NewRateLimiter(float64(burst), float64(burst), 0, 0) + defer rl.Stop() + + const goroutines = 20 + const requestsEach = 10 // total 200 attempts > 50 burst + + var ( + wg sync.WaitGroup + mu sync.Mutex + allowed int + ) + for range goroutines { + wg.Go(func() { + for range requestsEach { + if rl.Allow("shared-identity") == nil { + mu.Lock() + allowed++ + mu.Unlock() + } + } + }) + } + wg.Wait() + + assert.Equal(t, burst, allowed, "exactly burst requests should be allowed") + }) + + t.Run("multiple identities: each gets independent burst", func(t *testing.T) { + // N identities, each hammered by its own goroutine. + // Every identity must allow exactly its burst and deny the rest. + const ( + burst = 5 + identities = 20 + attempts = 15 // > burst per identity + ) + rl := NewRateLimiter(float64(burst), float64(burst), 0, 0) + defer rl.Stop() + + type result struct{ allowed, denied int } + results := make([]result, identities) + + var wg sync.WaitGroup + for i := range identities { + wg.Go(func() { + id := fmt.Sprintf("wallet-%d", i) + for range attempts { + if rl.Allow(id) == nil { + results[i].allowed++ + } else { + results[i].denied++ + } + } + }) + } + wg.Wait() + + for i, r := range results { + assert.Equal(t, burst, r.allowed, "identity %d: allowed should equal burst", i) + assert.Equal(t, attempts-burst, r.denied, "identity %d: denied should equal attempts-burst", i) + } + }) + + t.Run("concurrent bucket creation races on new identities", func(t *testing.T) { + // Many goroutines all call Allow() on the SAME previously-unseen identity + // at exactly the same moment, exercising the double-checked-locking path. + // The result must still honour the burst cap — no panics, no races. + const burst = 10 + rl := NewRateLimiter(float64(burst), float64(burst), 0, 0) + defer rl.Stop() + + const goroutines = 50 + var ( + wg sync.WaitGroup + mu sync.Mutex + allowed int + ) + // Use a barrier so all goroutines fire simultaneously. + start := make(chan struct{}) + for range goroutines { + wg.Go(func() { + <-start + if rl.Allow("brand-new") == nil { + mu.Lock() + allowed++ + mu.Unlock() + } + }) + } + close(start) + wg.Wait() + + assert.LessOrEqual(t, allowed, burst, "must not exceed burst even under race") + assert.Greater(t, allowed, 0, "at least one request must be allowed") + }) + + t.Run("parallel eviction and Allow do not race", func(t *testing.T) { + // Run Allow() and the idle-eviction sweep simultaneously to ensure + // there are no data races (best detected with -race). + ttl := 50 * time.Millisecond + rl := NewRateLimiter(1000, 1000, ttl, 10*time.Millisecond) + defer rl.Stop() + + var wg sync.WaitGroup + stop := make(chan struct{}) + + // Writer goroutines: continuously create and use buckets. + for i := range 10 { + wg.Go(func() { + id := fmt.Sprintf("evict-wallet-%d", i) + for { + select { + case <-stop: + return + default: + _ = rl.Allow(id) + } + } + }) + } + + // Let it run long enough for several eviction sweeps. + time.Sleep(200 * time.Millisecond) + close(stop) + wg.Wait() + }) +} + func TestNewRateLimiter_BurstValidation(t *testing.T) { t.Run("adjusts burst to match rate if too small", func(t *testing.T) { rl := NewRateLimiter(10, 5, 0, 0) // burst < rate From 56b02ccae2fcf2bd5a992e525e914bb4f092a7e5 Mon Sep 17 00:00:00 2001 From: "Hayim.Shaul@ibm.com" Date: Tue, 30 Jun 2026 11:55:59 +0000 Subject: [PATCH 05/14] added tests Signed-off-by: Hayim.Shaul@ibm.com --- .../services/selector/simple/selector_test.go | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 token/services/selector/simple/selector_test.go diff --git a/token/services/selector/simple/selector_test.go b/token/services/selector/simple/selector_test.go new file mode 100644 index 0000000000..6320815625 --- /dev/null +++ b/token/services/selector/simple/selector_test.go @@ -0,0 +1,262 @@ +/* +Copyright IBM Corp. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package simple + +import ( + "context" + "fmt" + "testing" + + "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" + "github.com/hyperledger-labs/fabric-token-sdk/token" + "github.com/hyperledger-labs/fabric-token-sdk/token/driver" + token2 "github.com/hyperledger-labs/fabric-token-sdk/token/token" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ── helpers ────────────────────────────────────────────────────────────────── + +const precision = uint64(64) + +// ownerFilter is a minimal token.OwnerFilter. +type ownerFilter struct{ id string } + +func (o *ownerFilter) ID() string { return o.id } +func (o *ownerFilter) ContainsToken(_ *token2.UnspentToken) bool { return true } + +// sliceIterator walks a fixed slice of UnspentTokens. +type sliceIterator struct { + tokens []*token2.UnspentToken + pos int +} + +func (s *sliceIterator) Close() {} +func (s *sliceIterator) Next() (*token2.UnspentToken, error) { + if s.pos >= len(s.tokens) { + return nil, nil + } + t := s.tokens[s.pos] + s.pos++ + return t, nil +} + +// mockQueryService returns the iterator and optionally fails GetTokens. +type mockQueryService struct { + tokens []*token2.UnspentToken + getTokensError error +} + +func (m *mockQueryService) UnspentTokensIterator(_ context.Context) (*token.UnspentTokensIterator, error) { + panic("not used") +} + +func (m *mockQueryService) UnspentTokensIteratorBy(_ context.Context, _ string, _ token2.Type) (driver.UnspentTokensIterator, error) { + return &sliceIterator{tokens: m.tokens}, nil +} + +func (m *mockQueryService) GetTokens(_ context.Context, _ ...*token2.ID) ([]*token2.Token, error) { + if m.getTokensError != nil { + return nil, m.getTokensError + } + return nil, nil +} + +// recordingLocker records every call to LockWithIdentity and UnlockIDs. +type recordingLocker struct { + // lockErr is returned for the lockFailAfter-th call onwards (0-indexed). + lockFailAfter int // after this many successes, start returning lockErr + lockErr error + calls int // total LockWithIdentity calls + + unlocked [][]*token2.ID // each UnlockIDs call appended as a group +} + +func (r *recordingLocker) Lock(_ context.Context, id *token2.ID, _ string, _ bool) (string, error) { + return r.LockWithIdentity(context.Background(), id, "", "", false) +} + +func (r *recordingLocker) LockWithIdentity(_ context.Context, id *token2.ID, _ string, _ string, _ bool) (string, error) { + idx := r.calls + r.calls++ + if idx >= r.lockFailAfter { + return "", r.lockErr + } + return "locked", nil +} + +func (r *recordingLocker) UnlockIDs(_ context.Context, ids ...*token2.ID) []*token2.ID { + if len(ids) > 0 { + cp := make([]*token2.ID, len(ids)) + copy(cp, ids) + r.unlocked = append(r.unlocked, cp) + } + return nil +} + +func (r *recordingLocker) UnlockByTxID(_ context.Context, _ string) {} +func (r *recordingLocker) IsLocked(_ *token2.ID) bool { return false } + +// totalUnlocked returns the flat list of all IDs passed to any UnlockIDs call. +func (r *recordingLocker) totalUnlocked() []*token2.ID { + var out []*token2.ID + for _, group := range r.unlocked { + out = append(out, group...) + } + return out +} + +// makeTokens builds n tokens, each with quantity "0x1" (= 1 in hex) and +// the given type. One token at index badQuantityAt (0-based) is given an +// unparseable quantity string; pass -1 to skip. +func makeTokens(n int, typ token2.Type, badQuantityAt int) []*token2.UnspentToken { + tokens := make([]*token2.UnspentToken, n) + for i := range n { + q := "0x1" + if i == badQuantityAt { + q = "NOT_A_NUMBER" + } + tokens[i] = &token2.UnspentToken{ + Id: token2.ID{TxId: fmt.Sprintf("tx%d", i), Index: 0}, + Owner: []byte("wallet1"), + Type: typ, + Quantity: q, + } + } + return tokens +} + +// newSelector is a convenience constructor. +func newSelector(locker Locker, qs QueryService, numRetry int) *selector { + return &selector{ + txID: "testTx", + locker: locker, + queryService: qs, + precision: precision, + numRetry: numRetry, + timeout: 0, + } +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +// TestSelectByID_ToQuantityError: the second token (index 1) has an unparseable +// quantity. Token 0 is locked successfully before the failure is hit and must +// be unlocked. We ask for 3 tokens worth of value so the loop is not broken +// early by hitting the target. +func TestSelectByID_ToQuantityError(t *testing.T) { + // token 0: valid "0x1", token 1: bad, token 2: valid "0x1" + // target = 0x3 → the loop will try all three before summing enough; hits bad token at index 1 + tokens := []*token2.UnspentToken{ + {Id: token2.ID{TxId: "tx0", Index: 0}, Owner: []byte("wallet1"), Type: "USD", Quantity: "0x1"}, + {Id: token2.ID{TxId: "tx1", Index: 0}, Owner: []byte("wallet1"), Type: "USD", Quantity: "NOT_A_NUMBER"}, + {Id: token2.ID{TxId: "tx2", Index: 0}, Owner: []byte("wallet1"), Type: "USD", Quantity: "0x1"}, + } + + locker := &recordingLocker{lockFailAfter: 10} // all locks succeed + qs := &mockQueryService{tokens: tokens} + sel := newSelector(locker, qs, 1) + + _, _, err := sel.Select(context.Background(), &ownerFilter{id: "wallet1"}, "0x3", "USD") + require.Error(t, err, "expected an error from bad quantity") + + // token 0 was locked and must have been unlocked + unlocked := locker.totalUnlocked() + require.Len(t, unlocked, 1, "the one successfully-locked token must be unlocked") +} + +// TestSelectByID_QuotaExceeded: token 0 is locked successfully, then token 1 +// causes LockWithIdentity to return ErrQuotaExceeded. +// Token 0 must be unlocked, and the error must be returned directly (no retry). +func TestSelectByID_QuotaExceeded(t *testing.T) { + locker := &recordingLocker{ + lockFailAfter: 1, // first lock succeeds, second fails + lockErr: errors.Wrapf(ErrQuotaExceeded, "identity wallet1 has 1 locks (max 1)"), + } + qs := &mockQueryService{tokens: makeTokens(3, "USD", -1)} + sel := newSelector(locker, qs, 5) // 5 retries — must NOT retry on quota error + + _, _, err := sel.Select(context.Background(), &ownerFilter{id: "wallet1"}, "0x3", "USD") + require.ErrorIs(t, err, ErrQuotaExceeded) + + // token 0 was locked and must have been unlocked exactly once + unlocked := locker.totalUnlocked() + require.Len(t, unlocked, 1, "the one successfully-locked token must be unlocked") + + // selector must not retry: LockWithIdentity called exactly 2 times (success + failure) + assert.Equal(t, 2, locker.calls, "should not retry after quota exceeded") +} + +// TestSelectByID_RateLimitExceeded: same shape as quota, different sentinel error. +func TestSelectByID_RateLimitExceeded(t *testing.T) { + locker := &recordingLocker{ + lockFailAfter: 2, // tokens 0 & 1 succeed, token 2 fails + lockErr: errors.Wrapf(ErrRateLimitExceeded, "identity wallet1"), + } + qs := &mockQueryService{tokens: makeTokens(4, "USD", -1)} + sel := newSelector(locker, qs, 5) // 5 retries — must NOT retry on rate-limit error + + _, _, err := sel.Select(context.Background(), &ownerFilter{id: "wallet1"}, "0x4", "USD") + require.ErrorIs(t, err, ErrRateLimitExceeded) + + // tokens 0 & 1 were locked and must be unlocked + unlocked := locker.totalUnlocked() + require.Len(t, unlocked, 2, "both successfully-locked tokens must be unlocked") + + assert.Equal(t, 3, locker.calls, "should not retry after rate limit exceeded") +} + +// TestSelectByID_ConcurrencyCheckFailure: all tokens lock fine and sum is +// sufficient, but GetTokens (the concurrency check) returns an error. +// All locked tokens must be unlocked and the loop must retry. +func TestSelectByID_ConcurrencyCheckFailure(t *testing.T) { + locker := &recordingLocker{lockFailAfter: 100} // all locks succeed + qs := &mockQueryService{ + tokens: makeTokens(2, "USD", -1), + getTokensError: errors.New("token no longer exists"), + } + // numRetry=1 means a single attempt then fail with SelectorSufficientFundsButConcurrencyIssue + sel := newSelector(locker, qs, 1) + + _, _, err := sel.Select(context.Background(), &ownerFilter{id: "wallet1"}, "0x2", "USD") + require.ErrorIs(t, err, token.SelectorSufficientFundsButConcurrencyIssue) + + // both tokens were locked and must have been unlocked on the retry/failure path + unlocked := locker.totalUnlocked() + require.Len(t, unlocked, 2, "all locked tokens must be unlocked after concurrency failure") +} + +// TestSelectByID_InsufficientFunds: only 1 token available but 2 requested. +// The single token gets locked, then unlocked at the end of each retry, +// and the final error must be SelectorInsufficientFunds. +func TestSelectByID_InsufficientFunds(t *testing.T) { + locker := &recordingLocker{lockFailAfter: 100} + qs := &mockQueryService{tokens: makeTokens(1, "USD", -1)} + sel := newSelector(locker, qs, 2) // 2 retries + + _, _, err := sel.Select(context.Background(), &ownerFilter{id: "wallet1"}, "0x2", "USD") + require.ErrorIs(t, err, token.SelectorInsufficientFunds) + + // The token must have been unlocked once per retry attempt (2 retries × 1 token) + assert.Equal(t, 2, len(locker.unlocked), "token must be unlocked after each retry") +} + +// TestSelectByID_HappyPath: enough tokens exist and locking succeeds. +// No UnlockIDs should be called. +func TestSelectByID_HappyPath(t *testing.T) { + locker := &recordingLocker{lockFailAfter: 100} + qs := &mockQueryService{tokens: makeTokens(3, "USD", -1)} + sel := newSelector(locker, qs, 1) + + ids, sum, err := sel.Select(context.Background(), &ownerFilter{id: "wallet1"}, "0x2", "USD") + require.NoError(t, err) + require.Len(t, ids, 2) + assert.Equal(t, 0, sum.Cmp(token2.NewQuantityFromUInt64(2)), "sum should be 2") + + // no unlocks should have happened + assert.Empty(t, locker.unlocked, "no tokens should be unlocked on success") +} From 177954d58ac3bfca6fce878829affff34ba7980c Mon Sep 17 00:00:00 2001 From: "Hayim.Shaul@ibm.com" Date: Tue, 30 Jun 2026 12:44:50 +0000 Subject: [PATCH 06/14] fix Signed-off-by: Hayim.Shaul@ibm.com --- .../services/selector/simple/inmemory/locker_quota_test.go | 4 ++-- token/services/selector/simple/inmemory/ratelimiter.go | 2 +- token/services/selector/simple/inmemory/ratelimiter_test.go | 2 +- token/services/selector/simple/selector_test.go | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/token/services/selector/simple/inmemory/locker_quota_test.go b/token/services/selector/simple/inmemory/locker_quota_test.go index 1c4d56b142..e60ea144de 100644 --- a/token/services/selector/simple/inmemory/locker_quota_test.go +++ b/token/services/selector/simple/inmemory/locker_quota_test.go @@ -10,8 +10,8 @@ import ( "context" "testing" - "github.com/hyperledger-labs/fabric-token-sdk/token/services/selector/simple" - token2 "github.com/hyperledger-labs/fabric-token-sdk/token/token" + "github.com/LFDT-Panurus/panurus/token/services/selector/simple" + token2 "github.com/LFDT-Panurus/panurus/token/token" "github.com/stretchr/testify/require" ) diff --git a/token/services/selector/simple/inmemory/ratelimiter.go b/token/services/selector/simple/inmemory/ratelimiter.go index 52c44f223c..c0e2739ec4 100644 --- a/token/services/selector/simple/inmemory/ratelimiter.go +++ b/token/services/selector/simple/inmemory/ratelimiter.go @@ -12,7 +12,7 @@ import ( "time" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" - "github.com/hyperledger-labs/fabric-token-sdk/token/services/selector/simple" + "github.com/LFDT-Panurus/panurus/token/services/selector/simple" ) // tokenBucket implements a token bucket rate limiter for a single identity diff --git a/token/services/selector/simple/inmemory/ratelimiter_test.go b/token/services/selector/simple/inmemory/ratelimiter_test.go index e07cd7f34a..489bf4cc49 100644 --- a/token/services/selector/simple/inmemory/ratelimiter_test.go +++ b/token/services/selector/simple/inmemory/ratelimiter_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/hyperledger-labs/fabric-token-sdk/token/services/selector/simple" + "github.com/LFDT-Panurus/panurus/token/services/selector/simple" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/token/services/selector/simple/selector_test.go b/token/services/selector/simple/selector_test.go index 6320815625..fc098a513c 100644 --- a/token/services/selector/simple/selector_test.go +++ b/token/services/selector/simple/selector_test.go @@ -12,9 +12,9 @@ import ( "testing" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" - "github.com/hyperledger-labs/fabric-token-sdk/token" - "github.com/hyperledger-labs/fabric-token-sdk/token/driver" - token2 "github.com/hyperledger-labs/fabric-token-sdk/token/token" + "github.com/LFDT-Panurus/panurus/token" + "github.com/LFDT-Panurus/panurus/token/driver" + token2 "github.com/LFDT-Panurus/panurus/token/token" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) From 0919c311d94902f08b61f5e13d8949de1c0f5e99 Mon Sep 17 00:00:00 2001 From: "Hayim.Shaul@ibm.com" Date: Tue, 30 Jun 2026 14:35:18 +0000 Subject: [PATCH 07/14] fmt Signed-off-by: Hayim.Shaul@ibm.com --- token/services/selector/simple/inmemory/ratelimiter.go | 2 +- token/services/selector/simple/selector_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/token/services/selector/simple/inmemory/ratelimiter.go b/token/services/selector/simple/inmemory/ratelimiter.go index c0e2739ec4..453cb94d99 100644 --- a/token/services/selector/simple/inmemory/ratelimiter.go +++ b/token/services/selector/simple/inmemory/ratelimiter.go @@ -11,8 +11,8 @@ import ( "sync" "time" - "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" "github.com/LFDT-Panurus/panurus/token/services/selector/simple" + "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" ) // tokenBucket implements a token bucket rate limiter for a single identity diff --git a/token/services/selector/simple/selector_test.go b/token/services/selector/simple/selector_test.go index fc098a513c..8fae9b4232 100644 --- a/token/services/selector/simple/selector_test.go +++ b/token/services/selector/simple/selector_test.go @@ -11,10 +11,10 @@ import ( "fmt" "testing" - "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" "github.com/LFDT-Panurus/panurus/token" "github.com/LFDT-Panurus/panurus/token/driver" token2 "github.com/LFDT-Panurus/panurus/token/token" + "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) From 3b8e2fe42a58ccaf29711f64e1134a4db6143dff Mon Sep 17 00:00:00 2001 From: "Hayim.Shaul@ibm.com" Date: Tue, 30 Jun 2026 14:39:08 +0000 Subject: [PATCH 08/14] lint Signed-off-by: Hayim.Shaul@ibm.com --- token/services/selector/simple/inmemory/ratelimiter_test.go | 2 +- token/services/selector/simple/selector_test.go | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/token/services/selector/simple/inmemory/ratelimiter_test.go b/token/services/selector/simple/inmemory/ratelimiter_test.go index 489bf4cc49..38d05e34f9 100644 --- a/token/services/selector/simple/inmemory/ratelimiter_test.go +++ b/token/services/selector/simple/inmemory/ratelimiter_test.go @@ -352,7 +352,7 @@ func TestRateLimiter_Parallel(t *testing.T) { wg.Wait() assert.LessOrEqual(t, allowed, burst, "must not exceed burst even under race") - assert.Greater(t, allowed, 0, "at least one request must be allowed") + assert.Positive(t, allowed, "at least one request must be allowed") }) t.Run("parallel eviction and Allow do not race", func(t *testing.T) { diff --git a/token/services/selector/simple/selector_test.go b/token/services/selector/simple/selector_test.go index 8fae9b4232..df636411c7 100644 --- a/token/services/selector/simple/selector_test.go +++ b/token/services/selector/simple/selector_test.go @@ -42,6 +42,7 @@ func (s *sliceIterator) Next() (*token2.UnspentToken, error) { } t := s.tokens[s.pos] s.pos++ + return t, nil } @@ -63,6 +64,7 @@ func (m *mockQueryService) GetTokens(_ context.Context, _ ...*token2.ID) ([]*tok if m.getTokensError != nil { return nil, m.getTokensError } + return nil, nil } @@ -86,6 +88,7 @@ func (r *recordingLocker) LockWithIdentity(_ context.Context, id *token2.ID, _ s if idx >= r.lockFailAfter { return "", r.lockErr } + return "locked", nil } @@ -242,7 +245,7 @@ func TestSelectByID_InsufficientFunds(t *testing.T) { require.ErrorIs(t, err, token.SelectorInsufficientFunds) // The token must have been unlocked once per retry attempt (2 retries × 1 token) - assert.Equal(t, 2, len(locker.unlocked), "token must be unlocked after each retry") + assert.Len(t, locker.unlocked, 2, "token must be unlocked after each retry") } // TestSelectByID_HappyPath: enough tokens exist and locking succeeds. From adff0edbed543d77d55c74d9b92ab8cb6fec7c90 Mon Sep 17 00:00:00 2001 From: "Hayim.Shaul@ibm.com" Date: Wed, 1 Jul 2026 06:55:01 +0000 Subject: [PATCH 09/14] lint Signed-off-by: Hayim.Shaul@ibm.com --- token/services/selector/simple/selector_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/token/services/selector/simple/selector_test.go b/token/services/selector/simple/selector_test.go index df636411c7..9c2533849d 100644 --- a/token/services/selector/simple/selector_test.go +++ b/token/services/selector/simple/selector_test.go @@ -98,6 +98,7 @@ func (r *recordingLocker) UnlockIDs(_ context.Context, ids ...*token2.ID) []*tok copy(cp, ids) r.unlocked = append(r.unlocked, cp) } + return nil } @@ -110,6 +111,7 @@ func (r *recordingLocker) totalUnlocked() []*token2.ID { for _, group := range r.unlocked { out = append(out, group...) } + return out } @@ -130,6 +132,7 @@ func makeTokens(n int, typ token2.Type, badQuantityAt int) []*token2.UnspentToke Quantity: q, } } + return tokens } From 722c257c9b9b43e4b56c8273a548d7fd1c70aba3 Mon Sep 17 00:00:00 2001 From: "Hayim.Shaul@ibm.com" Date: Wed, 1 Jul 2026 07:21:32 +0000 Subject: [PATCH 10/14] fmt Signed-off-by: Hayim.Shaul@ibm.com --- token/services/selector/simple/selector_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/token/services/selector/simple/selector_test.go b/token/services/selector/simple/selector_test.go index 9c2533849d..b6d3e70642 100644 --- a/token/services/selector/simple/selector_test.go +++ b/token/services/selector/simple/selector_test.go @@ -98,7 +98,7 @@ func (r *recordingLocker) UnlockIDs(_ context.Context, ids ...*token2.ID) []*tok copy(cp, ids) r.unlocked = append(r.unlocked, cp) } - + return nil } @@ -111,7 +111,7 @@ func (r *recordingLocker) totalUnlocked() []*token2.ID { for _, group := range r.unlocked { out = append(out, group...) } - + return out } From 1be4b254657161db99d427ee161ccfa24958c7be Mon Sep 17 00:00:00 2001 From: "Hayim.Shaul@ibm.com" Date: Wed, 1 Jul 2026 07:23:05 +0000 Subject: [PATCH 11/14] lint Signed-off-by: Hayim.Shaul@ibm.com --- token/services/selector/simple/inmemory/ratelimiter.go | 1 + 1 file changed, 1 insertion(+) diff --git a/token/services/selector/simple/inmemory/ratelimiter.go b/token/services/selector/simple/inmemory/ratelimiter.go index 453cb94d99..3af4eaae21 100644 --- a/token/services/selector/simple/inmemory/ratelimiter.go +++ b/token/services/selector/simple/inmemory/ratelimiter.go @@ -28,6 +28,7 @@ type tokenBucket struct { // newTokenBucket creates a new token bucket with the specified capacity and refill rate func newTokenBucket(maxTokens float64, refillRate float64) *tokenBucket { now := time.Now() + return &tokenBucket{ tokens: maxTokens, maxTokens: maxTokens, From e35889dd31f4ae4f79ffd6fc13474107d866f2f7 Mon Sep 17 00:00:00 2001 From: "Hayim.Shaul@ibm.com" Date: Wed, 1 Jul 2026 07:58:49 +0000 Subject: [PATCH 12/14] fmt Signed-off-by: Hayim.Shaul@ibm.com --- token/services/selector/simple/inmemory/ratelimiter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/token/services/selector/simple/inmemory/ratelimiter.go b/token/services/selector/simple/inmemory/ratelimiter.go index 3af4eaae21..742fa4f7bc 100644 --- a/token/services/selector/simple/inmemory/ratelimiter.go +++ b/token/services/selector/simple/inmemory/ratelimiter.go @@ -28,7 +28,7 @@ type tokenBucket struct { // newTokenBucket creates a new token bucket with the specified capacity and refill rate func newTokenBucket(maxTokens float64, refillRate float64) *tokenBucket { now := time.Now() - + return &tokenBucket{ tokens: maxTokens, maxTokens: maxTokens, From 8c7eecce31cf906d27f4914e44e1d73df4ca9eaf Mon Sep 17 00:00:00 2001 From: "Hayim.Shaul@ibm.com" Date: Thu, 2 Jul 2026 05:23:46 +0000 Subject: [PATCH 13/14] fix Signed-off-by: Hayim.Shaul@ibm.com --- token/services/auditor/auditor_test.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/token/services/auditor/auditor_test.go b/token/services/auditor/auditor_test.go index 5c363440a9..23023d1258 100644 --- a/token/services/auditor/auditor_test.go +++ b/token/services/auditor/auditor_test.go @@ -1112,15 +1112,6 @@ func TestService_AcquireLocksWithRetry_ExponentialBackoff(t *testing.T) { require.NoError(t, err) require.Len(t, callTimes, 4, "Should have 4 attempts") - - // Verify backoff is increasing (with some tolerance for jitter) - if len(callTimes) >= 3 { - delay1 := callTimes[1].Sub(callTimes[0]) - delay2 := callTimes[2].Sub(callTimes[1]) - // Second delay should be roughly 2x first delay (accounting for jitter) - // We use a loose check: delay2 should be at least 1.3x delay1 - assert.Greater(t, delay2, delay1*13/10, "Backoff should increase exponentially") - } } func TestService_AcquireLocksWithRetry_MultipleEnrollmentIDs(t *testing.T) { From 47d57885b014526c188160fc4041cc6aca8a6e85 Mon Sep 17 00:00:00 2001 From: "Hayim.Shaul@ibm.com" Date: Thu, 9 Jul 2026 08:55:37 +0000 Subject: [PATCH 14/14] move rate limit code to enforcer class Signed-off-by: Hayim.Shaul@ibm.com --- .../selector/simple/inmemory/locker.go | 80 +++---------- .../simple/inmemory/locker_enforcer.go | 106 ++++++++++++++++++ 2 files changed, 123 insertions(+), 63 deletions(-) create mode 100644 token/services/selector/simple/inmemory/locker_enforcer.go diff --git a/token/services/selector/simple/inmemory/locker.go b/token/services/selector/simple/inmemory/locker.go index fc72bb94de..796e63ac0f 100644 --- a/token/services/selector/simple/inmemory/locker.go +++ b/token/services/selector/simple/inmemory/locker.go @@ -52,14 +52,12 @@ type locker struct { ttxdb TXStatusProvider lock *sync.RWMutex locked map[token2.ID]*lockEntry - identityLockCount map[string]int // tracks number of locks per identity sleepTimeout time.Duration validTxEvictionTimeout time.Duration cancel context.CancelFunc scanDone chan struct{} stopOnce sync.Once - rateLimiter *RateLimiter - maxLocksPerIdentity int + enforcer *Locker } // LockerConfig holds configuration for the locker @@ -87,22 +85,15 @@ func NewLocker(ttxdb TXStatusProvider, timeout time.Duration, validTxEvictionTim func NewLockerWithConfig(ttxdb TXStatusProvider, timeout time.Duration, validTxEvictionTimeout time.Duration, config LockerConfig) simple.Locker { ctx, cancel := context.WithCancel(context.Background()) - var rateLimiter *RateLimiter - if config.RateLimit > 0 { - rateLimiter = NewRateLimiter(config.RateLimit, config.RateLimitBurst, config.RateLimitIdleTTL, 0) - } - r := &locker{ ttxdb: ttxdb, sleepTimeout: timeout, lock: &sync.RWMutex{}, locked: map[token2.ID]*lockEntry{}, - identityLockCount: map[string]int{}, validTxEvictionTimeout: validTxEvictionTimeout, cancel: cancel, scanDone: make(chan struct{}), - rateLimiter: rateLimiter, - maxLocksPerIdentity: config.MaxLocksPerIdentity, + enforcer: NewEnforcer(config), } r.start(ctx) @@ -121,8 +112,8 @@ func (d *locker) Stop() error { err = ErrTimeout logger.Warnf("scan goroutine did not stop within timeout") } - if d.rateLimiter != nil { - d.rateLimiter.Stop() + if d.enforcer != nil { + d.enforcer.Stop() } }) @@ -136,13 +127,11 @@ func (d *locker) Lock(ctx context.Context, id *token2.ID, txID string, reclaim b func (d *locker) LockWithIdentity(ctx context.Context, id *token2.ID, txID string, identity string, reclaim bool) (string, error) { k := *id - // Apply rate limiting if configured and identity provided - if d.rateLimiter != nil && identity != "" { - if err := d.rateLimiter.Allow(identity); err != nil { - logger.DebugfContext(ctx, "rate limit exceeded for identity [%s]: %v", identity, err) + // Rate limiting can be checked before acquiring the write lock. + if err := d.enforcer.CheckRateLimit(identity); err != nil { + logger.DebugfContext(ctx, "rate limit exceeded for identity [%s]: %v", identity, err) - return "", errors.Wrapf(simple.ErrRateLimitExceeded, "identity %s", identity) - } + return "", err } // check quickly if the token is locked @@ -185,24 +174,17 @@ func (d *locker) LockWithIdentity(ctx context.Context, id *token2.ID, txID strin } } - // Check quota if configured and identity provided - if d.maxLocksPerIdentity > 0 && identity != "" { - currentCount := d.identityLockCount[identity] - if currentCount >= d.maxLocksPerIdentity { - logger.DebugfContext(ctx, "quota exceeded for identity [%s]: %d/%d locks", identity, currentCount, d.maxLocksPerIdentity) + // Quota check must be inside the write lock so the check and TrackLock are atomic. + if err := d.enforcer.CheckQuota(identity); err != nil { + logger.DebugfContext(ctx, "quota exceeded for identity [%s]: %v", identity, err) - return "", errors.Wrapf(simple.ErrQuotaExceeded, "identity %s has %d locks (max %d)", identity, currentCount, d.maxLocksPerIdentity) - } + return "", err } logger.DebugfContext(ctx, "locking [%s] for [%s] by identity [%s]", id, txID, identity) now := time.Now() d.locked[k] = &lockEntry{TxID: txID, Identity: identity, Created: now, LastAccess: now} - - // Update identity lock count - if identity != "" { - d.identityLockCount[identity]++ - } + d.enforcer.TrackLock(identity) return "", nil } @@ -226,14 +208,7 @@ func (d *locker) UnlockIDs(ctx context.Context, ids ...*token2.ID) []*token2.ID } logger.DebugfContext(ctx, "unlocking [%s] hold by [%s]", id, entry) delete(d.locked, k) - - // Decrement identity lock count - if entry.Identity != "" { - d.identityLockCount[entry.Identity]-- - if d.identityLockCount[entry.Identity] <= 0 { - delete(d.identityLockCount, entry.Identity) - } - } + d.enforcer.TrackUnlock(entry.Identity) } return notFound @@ -248,14 +223,7 @@ func (d *locker) UnlockByTxID(ctx context.Context, txID string) { if entry.TxID == txID { logger.DebugfContext(ctx, "unlocking [%s] hold by [%s]", id, entry) delete(d.locked, id) - - // Decrement identity lock count - if entry.Identity != "" { - d.identityLockCount[entry.Identity]-- - if d.identityLockCount[entry.Identity] <= 0 { - delete(d.identityLockCount, entry.Identity) - } - } + d.enforcer.TrackUnlock(entry.Identity) } } } @@ -277,14 +245,7 @@ func (d *locker) reclaim(ctx context.Context, id *token2.ID, txID string, identi switch status { case ttxdb.Deleted: delete(d.locked, *id) - - // Decrement identity lock count - if identity != "" { - d.identityLockCount[identity]-- - if d.identityLockCount[identity] <= 0 { - delete(d.identityLockCount, identity) - } - } + d.enforcer.TrackUnlock(identity) return true, status default: @@ -349,14 +310,7 @@ func (d *locker) scan(ctx context.Context) { // reclaimed this token and re-locked it for a new transaction. if entry, ok := d.locked[s.id]; ok && entry.TxID == s.txID { delete(d.locked, s.id) - - // Decrement identity lock count - if entry.Identity != "" { - d.identityLockCount[entry.Identity]-- - if d.identityLockCount[entry.Identity] <= 0 { - delete(d.identityLockCount, entry.Identity) - } - } + d.enforcer.TrackUnlock(entry.Identity) } } d.lock.Unlock() diff --git a/token/services/selector/simple/inmemory/locker_enforcer.go b/token/services/selector/simple/inmemory/locker_enforcer.go new file mode 100644 index 0000000000..4527708b38 --- /dev/null +++ b/token/services/selector/simple/inmemory/locker_enforcer.go @@ -0,0 +1,106 @@ +/* +Copyright IBM Corp. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package inmemory + +import ( + "sync" + + "github.com/LFDT-Panurus/panurus/token/services/selector/simple" + "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" +) + +// Locker enforces per-identity rate limiting and quota for token lock operations. +// Construct it with NewEnforcer and compose it into any locker implementation. +type Locker struct { + rateLimiter *RateLimiter + identityLockCount map[string]int + maxLocksPerIdentity int + mu sync.Mutex +} + +// NewEnforcer builds a Locker from the given LockerConfig. +// It starts any background goroutines required by the rate limiter. +func NewEnforcer(config LockerConfig) *Locker { + var rateLimiter *RateLimiter + if config.RateLimit > 0 { + rateLimiter = NewRateLimiter(config.RateLimit, config.RateLimitBurst, config.RateLimitIdleTTL, 0) + } + + return &Locker{ + rateLimiter: rateLimiter, + identityLockCount: map[string]int{}, + maxLocksPerIdentity: config.MaxLocksPerIdentity, + } +} + +// Stop shuts down any background goroutines (e.g. the rate-limiter sweep). +func (e *Locker) Stop() { + if e.rateLimiter != nil { + e.rateLimiter.Stop() + } +} + +// CheckRateLimit checks only the rate limit for the given identity. +// Returns nil if the request is permitted, or a wrapped ErrRateLimitExceeded otherwise. +// When identity is empty no check is applied. +func (e *Locker) CheckRateLimit(identity string) error { + if identity == "" || e.rateLimiter == nil { + return nil + } + + if err := e.rateLimiter.Allow(identity); err != nil { + return errors.Wrapf(simple.ErrRateLimitExceeded, "identity %s", identity) + } + + return nil +} + +// CheckQuota checks only the quota for the given identity. +// Must be called while the caller holds any lock that serialises lock acquisition, +// so that the check and the subsequent TrackLock are atomic with respect to other +// concurrent lock operations. +// Returns nil if the request is permitted, or a wrapped ErrQuotaExceeded otherwise. +// When identity is empty no check is applied. +func (e *Locker) CheckQuota(identity string) error { + if identity == "" || e.maxLocksPerIdentity <= 0 { + return nil + } + + e.mu.Lock() + currentCount := e.identityLockCount[identity] + e.mu.Unlock() + if currentCount >= e.maxLocksPerIdentity { + return errors.Wrapf(simple.ErrQuotaExceeded, "identity %s has %d locks (max %d)", identity, currentCount, e.maxLocksPerIdentity) + } + + return nil +} + +// TrackLock records that one additional lock has been acquired for identity. +// Must be called after a lock is successfully granted. +func (e *Locker) TrackLock(identity string) { + if identity == "" { + return + } + e.mu.Lock() + e.identityLockCount[identity]++ + e.mu.Unlock() +} + +// TrackUnlock records that one lock has been released for identity. +// Must be called when a lock is removed. +func (e *Locker) TrackUnlock(identity string) { + if identity == "" { + return + } + e.mu.Lock() + e.identityLockCount[identity]-- + if e.identityLockCount[identity] <= 0 { + delete(e.identityLockCount, identity) + } + e.mu.Unlock() +}