Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature(provider): Rate limiting #15583

Open
wants to merge 22 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions provider/.op.env
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ SCROLL_PROVIDER=op://secrets/rpc-providers/scroll
SEPOLIA_PROVIDER=op://secrets/rpc-providers/sepolia
ZKEVM_PROVIDER=op://secrets/rpc-providers/zkevm
ZKSYNC_PROVIDER=op://secrets/rpc-providers/zksync
LOCK_CACHE_KV_ID=op://secrets/rpc-providers/lock-cache-kv-id
LOCKSMITH_SECRET_KEY=op://secrets/rpc-providers/locksmith-secret-key

40 changes: 39 additions & 1 deletion provider/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,45 @@ To modify the cache duration, simply update this value in the wrangler.toml file

If the environment variable contains an invalid value, the default 1-hour duration will be used.

## Lock Caching Optimizations

The provider uses a three-tier caching system for lock addresses to minimize blockchain calls:

1. **In-Memory Cache**: Unlimited-size, fast lookup storage that persists during worker runtime
2. **Cache API**: Edge-distributed cache with 24-hour TTL for frequently accessed locks
3. **KV Storage**: Durable storage (1-year TTL) that persists across worker restarts

**Key Features**:

- Automatic prefilling of memory cache on first request
- Non-blocking async operations to maintain performance
- Access pattern tracking for analytics
- No scheduled tasks required - optimized for standard worker environments

## Rate Limiting

The provider implements rate limiting to ensure fair usage of the service:

- 10 requests per 10 seconds per IP address/contract
- 1000 requests per hour per IP address

### Locksmith Authentication

Requests from Locksmith are exempt from rate limiting.

1. Locksmith appends a secret key to requests: `?secret=YOUR_SECRET_KEY`
2. Requests with a valid secret key bypass all rate limiting

### Unlock Contract Exemptions

Rate limiting can be configured in `wrangler.toml`:

```toml
[vars]
REQUESTS_PER_SECOND = "10" # Default: 10
REQUESTS_PER_HOUR = "1000" # Default: 1000
```

# Development

You can use the `yarn dev` to run locally.
Expand All @@ -58,4 +97,3 @@ Then set it in 1Password, under `secrets/rpc-providers`.
- Only support RPC calls to Unlock contracts (or related contracts... such as ERC20 contracts).
- Deploy through Github action
- Measure all the things
- Rate limiting
1 change: 1 addition & 0 deletions provider/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"devDependencies": {
"@cloudflare/workers-types": "4.20250129.0",
"@types/node": "22.13.0",
"@unlock-protocol/contracts": "workspace:^",
"@unlock-protocol/networks": "workspace:^",
"typescript": "5.7.3",
"vitest": "2.1.9"
Expand Down
18 changes: 17 additions & 1 deletion provider/scripts/set-provider-urls.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,24 @@ read -r -d '' FILE << EOM
"BASE_PROVIDER": "$BASE_PROVIDER",
"SEPOLIA_PROVIDER": "$SEPOLIA_PROVIDER",
"LINEA_PROVIDER": "$LINEA_PROVIDER",
"SCROLL_PROVIDER": "$SCROLL_PROVIDER"
"SCROLL_PROVIDER": "$SCROLL_PROVIDER",
"LOCK_CACHE_KV_ID": "$LOCK_CACHE_KV_ID",
"LOCKSMITH_SECRET_KEY": "$LOCKSMITH_SECRET_KEY"
}
EOM

# Check if LOCK_CACHE_KV_ID is set
if [ -z "$LOCK_CACHE_KV_ID" ]; then
echo "Warning: LOCK_CACHE_KV_ID environment variable is not set."
echo "The KV namespace for lock caching will not be configured correctly."
echo "Make sure to set this variable from 1Password before deploying to production."
fi

# Check if LOCKSMITH_SECRET_KEY is set
if [ -z "$LOCKSMITH_SECRET_KEY" ]; then
echo "Warning: LOCKSMITH_SECRET_KEY environment variable is not set."
echo "Locksmith authentication will not work correctly."
echo "Make sure to set this variable from 1Password before deploying to production."
fi

echo $FILE | yarn wrangler secret:bulk
54 changes: 54 additions & 0 deletions provider/src/handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import supportedNetworks from './supportedNetworks'
import { Env } from './types'
import {
checkRateLimit,
getContractAddress,
isUnlockContract,
getClientIP,
} from './rateLimit'

interface RpcRequest {
id: number
Expand Down Expand Up @@ -201,6 +207,54 @@ const handler = async (request: Request, env: Env): Promise<Response> => {
)
}

// Extract contract address if applicable
const contractAddress = getContractAddress(body.method, body.params)

// Check if this is an Unlock contract (skip rate limiting if true)
let isUnlock = false
if (contractAddress) {
isUnlock = await isUnlockContract(contractAddress, networkId, env)
}

// Only apply rate limiting if not an Unlock contract
if (!isUnlock) {
const isRateLimitAllowed = await checkRateLimit(
request,
body.method,
contractAddress,
env
)

if (!isRateLimitAllowed) {
// TEMPORARY: Log but don't block rate-limited requests for monitoring purposes
// After 10+ days, review logs and enable actual blocking
console.log(
`RATE_LIMIT_WOULD_BLOCK: IP=${getClientIP(request)}, Method=${body.method}, Contract=${contractAddress || 'none'}, ID=${body.id || 'none'}`
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, can we log the full body as well? This could be useful to debug more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(insead of just the ID which is not very useful!)


// Original blocking code - commented out for monitoring period
/*
return Response.json(
{
id: body.id || 42,
jsonrpc: '2.0',
error: {
code: -32005,
message: 'Rate limit exceeded',
},
},
{
status: 429,
headers: {
...headers,
'Retry-After': '60', // Suggest retry after 60 seconds
},
}
)
*/
}
}

// Check if this is a cacheable request
const isCacheable =
CACHEABLE_METHODS.includes(body.method) && isNameResolutionRequest(body)
Expand Down
32 changes: 32 additions & 0 deletions provider/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import handler from './handler'
import { Env } from './types'
import { prefillLockCache } from './unlockContracts'

// Flag to track if we've initialized the cache yet
let cacheInitialized = false

/**
* A proxy worker for JSON RPC endpoints
Expand All @@ -10,7 +14,35 @@ export default {
env: Env,
context: ExecutionContext
): Promise<Response> {
// Initialize the lock cache if it hasn't been done yet
// This improves performance by prefilling the memory cache with known locks
if (!cacheInitialized && env.LOCK_CACHE) {
// Don't block the current request on cache initialization
// In a no-cron environment, this is the only time the cache will be prefilled
context.waitUntil(
prefillLockCache(env).then(() => {
cacheInitialized = true
console.log(
'Cache initialization complete (no scheduled refresh enabled)'
)
})
)
}

context.passThroughOnException()
return await handler(request, env)
},

// This handler won't be called if cron triggers aren't configured
// Remains for documentation purposes and in case cron is enabled in the future
async scheduled(
_controller: ScheduledController,
env: Env,
context: ExecutionContext
): Promise<void> {
console.log(
'Running scheduled cache refresh (this only runs if cron triggers are configured)'
)
context.waitUntil(prefillLockCache(env))
},
} as ExportedHandler<Env>
148 changes: 148 additions & 0 deletions provider/src/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { Env } from './types'
import { isKnownUnlockContract, checkIsLock } from './unlockContracts'

/**
* Checks if the request has the correct Locksmith secret key
*/
export const hasValidLocksmithSecret = (
request: Request,
env: Env
): boolean => {
if (!env.LOCKSMITH_SECRET_KEY) return false

// Get the secret from the query parameter
const url = new URL(request.url)
const secret = url.searchParams.get('secret')

// Check if the secret matches
return secret === env.LOCKSMITH_SECRET_KEY
}

/**
* Check if a contract is an Unlock contract
* This uses a multi-step approach:
* 1. Check if it's a known Unlock contract address
* 2. If not, check if it's a lock by calling the Unlock contract
*/
export const isUnlockContract = async (
contractAddress: string,
networkId: string,
env: Env
): Promise<boolean> => {
if (!contractAddress) return false

try {
// First, check if it's a known Unlock contract
if (isKnownUnlockContract(contractAddress, networkId)) {
return true
}

// If not a known Unlock contract, check if it's a lock
return await checkIsLock(contractAddress, networkId, env)
} catch (error) {
console.error('Error checking if contract is Unlock contract:', error)
return false
}
}

/**
* Performs rate limiting check using Cloudflare's Rate Limiting API
* Returns true if the request should be allowed, false otherwise
*/
export const checkRateLimit = async (
request: Request,
method: string,
contractAddress: string | null,
env: Env
): Promise<boolean> => {
// Authenticated Locksmith requests are exempt from rate limiting
if (hasValidLocksmithSecret(request, env)) {
return true
}

// Get client IP for rate limiting
const ip = getClientIP(request)

try {
// Create a key that combines IP with contract address or method to provide granular rate limiting
// This is a more stable identifier than just IP alone, as recommended by Cloudflare
const rateKey = contractAddress
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In our case I think it will be almost exclusively POST though,,, but not a a big deal!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it!

? `${ip}:${contractAddress.toLowerCase()}`
: `${ip}:${method}`

// Check standard rate limiter (10 seconds period)
const standardResult = await env.STANDARD_RATE_LIMITER.limit({
key: rateKey,
})
if (!standardResult.success) {
return false
}

// Check hourly rate limiter (60 seconds period)
const hourlyResult = await env.HOURLY_RATE_LIMITER.limit({ key: ip })
return hourlyResult.success
} catch (error) {
console.error('Error checking rate limit:', error)
// In case of error, allow the request to proceed
// We don't want to block legitimate requests due to rate limiter failures
return true
}
}

/**
* Extract contract address from RPC method params
* This function supports common RPC methods that interact with contracts
*/
export const getContractAddress = (
method: string,
params: any[]
): string | null => {
if (!params || params.length === 0) return null

try {
// Common RPC methods that interact with contracts directly with 'to' field
if (
['eth_call', 'eth_estimateGas', 'eth_sendTransaction'].includes(method)
) {
const txParams = params[0]
if (txParams && typeof txParams === 'object' && 'to' in txParams) {
return txParams.to as string
}
}

// eth_getLogs and eth_getFilterLogs may contain contract address in 'address' field
if (['eth_getLogs', 'eth_getFilterLogs'].includes(method)) {
const filterParams = params[0]
if (
filterParams &&
typeof filterParams === 'object' &&
'address' in filterParams
) {
return filterParams.address as string
}
}

// eth_getCode, eth_getBalance, eth_getTransactionCount, eth_getStorageAt
// These methods have the address as the first parameter
if (
[
'eth_getCode',
'eth_getBalance',
'eth_getTransactionCount',
'eth_getStorageAt',
].includes(method)
) {
if (typeof params[0] === 'string') {
return params[0] as string
}
}

return null
} catch (error) {
console.error(
`Error extracting contract address from method ${method}:`,
error
)
return null
}
}
19 changes: 19 additions & 0 deletions provider/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,23 @@ export interface Env {

// Optional environment variable for configuring cache duration in seconds
CACHE_DURATION_SECONDS?: string

// Secret key for authenticating requests from Locksmith
LOCKSMITH_SECRET_KEY?: string

// Cloudflare Rate Limiting API bindings
STANDARD_RATE_LIMITER: RateLimiter
HOURLY_RATE_LIMITER: RateLimiter

// KV namespace for caching lock addresses
LOCK_CACHE?: KVNamespace
}

// Cloudflare Rate Limiting API interface
export interface RateLimiter {
limit(options: { key: string }): Promise<{ success: boolean }>
}

export interface ContractInfo {
isUnlockContract: boolean
}
Loading
Loading