Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
VITE_RPC_URL=https://rpc.ghostnet.teztnets.com
VITE_NETWORK_TYPE=ghostnet
VITE_FAUCET_URL=https://ghostnet.faucet.tezos.ecadinfra.com

VITE_REOWN_PROJECT_ID=

VITE_GIT_SHA=1e23456abc123ab11204caf40a442040e5ec99f9
VITE_VERSION=1.0.0

VITE_SENTRY_DSN=https://abc@123.ingest.us.sentry.io/123456
VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
VITE_SENTRY_DSN=https://abc@123.ingest.us.sentry.io/123456
3 changes: 3 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ jobs:
VITE_GITHUB_SHA: ${{ github.sha }}
VITE_VERSION: ${{ github.ref_name }}
VITE_REOWN_PROJECT_ID: ${{ secrets.REOWN_PROJECT_ID }}
VITE_FAUCET_URL: ${{ secrets.FAUCET_URL }}
VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_TURNSTILE_SITE_KEY: ${{ secrets.TURNSTILE_SITE_KEY }}
outputs:
preview-url: ${{ steps.cloudflare_publish.outputs.URL }}
environment: ${{ matrix.environment }}
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ dist-ssr
/playwright/.cache/

contract-config.json
/src/contracts/compiled/
/src/contracts/compiled/
src/cloudflare/faucet/.wrangler/
3 changes: 3 additions & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,7 @@ export default tseslint.config(
},
},
},
{
ignores: ["src/cloudflare/faucet/.wrangler"],
},
);
12 changes: 11 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
"vite-plugin-node-polyfills": "^0.24.0",
"vue": "^3.5.17",
"vue-router": "^4.5.1",
"vue-sonner": "^2.0.2"
"vue-sonner": "^2.0.2",
"vue-turnstile": "^1.0.11"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
Expand Down
111 changes: 111 additions & 0 deletions src/cloudflare/faucet/DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Production Deployment Guide

## Cloudflare Setup for Durable Objects

### 1. Enable Durable Objects

Durable Objects are required for persistent rate limiting. Follow these steps:

```bash
# 1. Set your Cloudflare account ID
npx wrangler config

# 2. Get your account ID from dashboard or:
npx wrangler whoami

# 3. Update wrangler.jsonc with your account ID
# Replace "account_id": "" with your actual account ID
```

### 2. Durable Objects Deployment

```bash
# Deploy with Durable Objects support
npx wrangler deploy

# Verify deployment includes Durable Objects
npx wrangler durable-objects list
```

#### Expected Output:

```
┌─────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────┐
│ Name │ Class Name │ Asset Hash │
├─────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
│ RATE_LIMITER │ RateLimiter │ abc123def456... │
└─────────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────────────────┘
```

## Pre-Deployment Checklist

### 1. Required Secrets (Set via `npx wrangler secret put`)

```bash
# Required secrets
npx wrangler secret put FAUCET_PRIVATE_KEY
npx wrangler secret put TURNSTILE_SECRET_KEY

# Optional secrets
npx wrangler secret put MAX_BALANCE
```

### 2. Environment Variables

Update `wrangler.jsonc` with production values:

```jsonc
{
"vars": {
"ALLOWED_ORIGINS": "ghostnet.dapp.taquito.io,seoulnet.dapp.taquito.io,shadownet.dapp.taquito.io",
"RPC_URL": "https://ghostnet.tezos.ecadinfra.com", // Default if user doesn't provide an RPC URL
"ALLOWED_RPC_URLS": "https://ghostnet.tezos.ecadinfra.com,https://shadownet.tezos.ecadinfra.com,https://seoulnet.tezos.ecadinfra.com",
"MIN_TEZ": "1",
"MAX_TEZ": "10",
"RATE_LIMIT_ENABLED": "true",
"RATE_LIMIT_WINDOW": "3600",
"RATE_LIMIT_MAX_REQUESTS": "5",
},
}
```

## Deployment Commands

### Development/Testing

```bash
# Install dependencies
cd src/cloudflare/faucet
npm install

# Run locally (place env vars in .dev.vars)
npx wrangler dev
```

### Production Deployment

```bash
# Deploy to Cloudflare Workers
npx wrangler deploy

# Deploy to preview environment
npx wrangler deploy --env preview
```

## Durable Objects Management

### Rate Limiting via Durable Objects

The faucet uses Durable Objects for persistent rate limiting across requests.

#### Rate Limiting Configuration

```jsonc
{
"vars": {
"RATE_LIMIT_ENABLED": "true",
"RATE_LIMIT_WINDOW": "3600", // 1 hour in seconds
"RATE_LIMIT_MAX_REQUESTS": "10", // Max requests per window per IP
},
}
```
167 changes: 167 additions & 0 deletions src/cloudflare/faucet/RateLimiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { DurableObject } from "cloudflare:workers";

interface RateLimitRequest {
clientIP: string;
windowSeconds: number;
maxRequests: number;
}

interface ResetRequest {
clientIP?: string;
}

export class RateLimiter extends DurableObject {
private rateLimitData: Map<string, number[]> = new Map();
private state: DurableObjectState;

constructor(state: DurableObjectState, env: Record<string, unknown>) {
super(state, env);
this.state = state;
}

async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const action = url.searchParams.get("action");

switch (action) {
case "check":
return await this.checkRateLimit(request);
case "reset":
return await this.resetRateLimit(request);
default:
return new Response("Invalid action", { status: 400 });
}
}

private async checkRateLimit(request: Request): Promise<Response> {
try {
const body = (await request.json()) as RateLimitRequest;
const { clientIP, windowSeconds, maxRequests } = body;

const now = Date.now();
const windowMs = windowSeconds * 1000;
const cutoff = now - windowMs;

// Get existing timestamps for this IP
const timestamps = this.rateLimitData.get(clientIP) || [];

// Filter out expired timestamps
const recentRequests = timestamps.filter(
(timestamp) => timestamp > cutoff,
);

// Check if rate limit exceeded
if (recentRequests.length >= maxRequests) {
return new Response(
JSON.stringify({
allowed: false,
remaining: 0,
resetTime: recentRequests[0] + windowMs,
message: `Rate limit exceeded. Max ${maxRequests} requests per ${windowSeconds} seconds.`,
}),
{
status: 429,
headers: { "Content-Type": "application/json" },
},
);
}

// Add current request timestamp
recentRequests.push(now);
this.rateLimitData.set(clientIP, recentRequests);

const remaining = Math.max(0, maxRequests - recentRequests.length);
const resetTime = recentRequests[0] + windowMs;

return new Response(
JSON.stringify({
allowed: true,
remaining,
resetTime,
requests: recentRequests.length,
}),
{
headers: { "Content-Type": "application/json" },
},
);
} catch (error) {
console.error("Rate limit check error:", error);
// Fail open - allow request if check fails
return new Response(
JSON.stringify({
allowed: true,
remaining: 1,
resetTime: Date.now() + 3600 * 1000,
error: "Rate limit check failed",
}),
{
headers: { "Content-Type": "application/json" },
},
);
}
}

private async resetRateLimit(request: Request): Promise<Response> {
try {
const body = (await request.json()) as ResetRequest;
const { clientIP } = body;

if (clientIP) {
this.rateLimitData.delete(clientIP);
return new Response(
JSON.stringify({
success: true,
message: `Rate limit reset for IP ${clientIP}`,
}),
{
headers: { "Content-Type": "application/json" },
},
);
} else {
// Reset all IPs
this.rateLimitData.clear();
return new Response(
JSON.stringify({
success: true,
message: "Rate limit reset for all IPs",
}),
{
headers: { "Content-Type": "application/json" },
},
);
}
} catch (error) {
console.error("Rate limit reset error:", error);
return new Response(
JSON.stringify({
success: false,
error: "Failed to reset rate limit",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
}

// Cleanup old data periodically
async alarm(): Promise<void> {
const now = Date.now();
const maxAge = 24 * 60 * 60 * 1000; // 24 hours

for (const [clientIP, timestamps] of this.rateLimitData.entries()) {
const recentTimestamps = timestamps.filter(
(timestamp) => now - timestamp < maxAge,
);
if (recentTimestamps.length === 0) {
this.rateLimitData.delete(clientIP);
} else {
this.rateLimitData.set(clientIP, recentTimestamps);
}
}

// Schedule next cleanup in 1 hour
await this.state.storage.setAlarm(Date.now() + 60 * 60 * 1000);
}
}
Loading