Skip to content
Open
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
48 changes: 48 additions & 0 deletions faucet-app/server/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# =============================================================================
# Nitrolite Faucet Server Configuration
# =============================================================================
# Copy this file to .env and update the values according to your setup
#
# You can also set these as environment variables instead of using .env file
# Environment variables take precedence over .env file values
# =============================================================================

# -----------------------------------------------------------------------------
# Server Configuration
# -----------------------------------------------------------------------------
# HTTP server port
SERVER_PORT=8080

# -----------------------------------------------------------------------------
# Nitronode Configuration
# -----------------------------------------------------------------------------
# Private key for faucet owner wallet (without 0x prefix)
# REQUIRED: Used for EIP-712 authentication with Nitronode
OWNER_PRIVATE_KEY=your_owner_private_key_here_without_0x_prefix

# Cooldown between requests per wallet/IP (Go duration format)
# Optional: default is 24h
# COOLDOWN_PERIOD=your_cooldown_period_here (e.g., 24h, 1h, 30m)

# Nitronode WebSocket URL
# REQUIRED: The WebSocket endpoint for your Nitronode instance
NITRONODE_URL=wss://nitronode.example.com/ws

# Token symbol to distribute
# REQUIRED: The symbol of the token to distribute (must be supported by Nitronode)
TOKEN_SYMBOL=usdc

# Default amount to send per request
# REQUIRED: Amount in decimal format (e.g., 1.0 = 1 USDC, 0.5 = 0.5 ETH)
STANDARD_TIP_AMOUNT=10.0

# Minimum number of transfers the server should have a balance for to operate
# REQUIRED: Integer value (e.g., 5)
MIN_TRANSFER_COUNT=5

# -----------------------------------------------------------------------------
# Logging Configuration
# -----------------------------------------------------------------------------
# Logging level (debug, info, warn, error)
# Default: info
LOG_LEVEL=info
1 change: 1 addition & 0 deletions faucet-app/server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.env
31 changes: 31 additions & 0 deletions faucet-app/server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
FROM golang:1.25-alpine AS builder

WORKDIR /build

# Copy root module files first (the replace directive in faucet-app/server points here)
COPY go.mod go.sum ./

# Copy faucet-app/server module files and pre-download dependencies
COPY faucet-app/server/go.mod faucet-app/server/go.sum ./faucet-app/server/
RUN cd faucet-app/server && go mod download
Comment on lines +8 to +10
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Remove references to deleted module files.

Lines 8-10 attempt to copy faucet-app/server/go.mod and go.sum, but per the PR objectives these files were removed when the faucet-app was migrated to use the root module. The Docker build will fail with a "file not found" error.

🐛 Proposed fix

Since the faucet-app now uses the root module, remove these lines entirely. Dependencies are already downloaded via the root go.mod copied at line 6:

-# Copy faucet-app/server module files and pre-download dependencies
-COPY faucet-app/server/go.mod faucet-app/server/go.sum ./faucet-app/server/
-RUN cd faucet-app/server && go mod download
+# Pre-download dependencies from root module
+RUN go mod download
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@faucet-app/server/Dockerfile` around lines 8 - 10, Remove the now-invalid
module-copy and download steps: delete the COPY command that references
"faucet-app/server/go.mod" and "faucet-app/server/go.sum" and the subsequent
"RUN cd faucet-app/server && go mod download" from the Dockerfile, since the
faucet-app was migrated to the root module and dependencies are handled by the
root go.mod already copied earlier.


# Copy full source tree so the replace directive can resolve
COPY . .

# Build the faucet server binary
RUN cd faucet-app/server && CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /build/bin/faucet-server .

FROM alpine:3.23.3

RUN apk --no-cache add ca-certificates

RUN addgroup -g 1001 -S faucet
RUN adduser -S faucet -u 1001 -G faucet

USER faucet

COPY --from=builder /build/bin/faucet-server /bin/faucet-server

EXPOSE 8080

CMD ["faucet-server"]
173 changes: 173 additions & 0 deletions faucet-app/server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# Nitrolite Faucet Server

A Go-based faucet server that distributes tokens through the Nitronode network using WebSocket connections.

## Features

- **Nitrolite SDK Integration**: Uses the local `github.com/layer-3/nitrolite` SDK for Nitronode communication
- **Ethereum Wallet Integration**: Uses ECDSA private key for signing channel states and transactions
- **RESTful API**: Simple HTTP endpoints for token requests
- **Structured Logging**: JSON-formatted logs with configurable levels
- **Graceful Shutdown**: Proper cleanup of connections and resources
- **Address Validation**: Validates Ethereum addresses before processing requests

## Architecture

The application is structured into several packages:

- `internal/config`: Configuration management with environment variables
- `internal/logger`: Structured logging with logrus
- `internal/nitronode`: Thin wrapper around the Nitrolite SDK client
- `internal/server`: HTTP server with Gin framework

### Nitronode Client

The `internal/nitronode` package wraps the Nitrolite SDK's `sdk.Client`. Connection and message signing are handled internally by the SDK — no manual WebSocket management is required.

## Quick Start

1. **Setup**:

```bash
cd faucet-app/server
go mod tidy
```

2. **Configure environment**:

```bash
cp .env.example .env
# Edit .env with your configuration
```

3. **Run the server**:

```bash
go run main.go
```

## Configuration

The application uses [cleanenv](https://github.com/ilyakaznacheev/cleanenv) for configuration management. Configuration can be provided via:

1. **`.env` file** in the current directory
2. **Environment variables** (used when `.env` is absent)

Set the following environment variables:

| Variable | Required | Default | Description | Example |
|----------|----------|---------|-------------|---------|
| `SERVER_PORT` | No | `8080` | HTTP server port | `8080` |
| `OWNER_PRIVATE_KEY` | **Yes** | - | Owner private key (without 0x prefix) — signs channel states and transfers | `abcdef123...` |
| `NITRONODE_URL` | **Yes** | - | Nitronode WebSocket URL | `wss://nitronode.example.com/ws` |
| `TOKEN_SYMBOL` | **Yes** | - | Token symbol to distribute | `usdc` |
| `STANDARD_TIP_AMOUNT` | **Yes** | - | Amount to send per request (decimal format) | `10.0` |
| `MIN_TRANSFER_COUNT` | **Yes** | - | Minimum number of transfers the server should have balance for | `5` |
| `COOLDOWN_PERIOD` | **Yes** | - | Cooldown between requests per wallet/IP (Go duration format) | `24h` |
| `TRUSTED_PROXIES` | No | `""` | Comma-separated trusted proxy IPs; empty means direct exposure only | `10.0.0.1,10.0.0.2` |
| `LOG_LEVEL` | No | `info` | Logging level (debug/info/warn/error) | `info` |

> **Note on `TRUSTED_PROXIES`:** If the faucet is deployed behind an ingress or load balancer, set this to the proxy IP(s). Without it, `c.ClientIP()` returns the proxy address and all requests share one IP rate-limit bucket.

## API Endpoints

### `POST /requestTokens`

Request tokens for an Ethereum address.

**Request body:**
```json
{ "userAddress": "0x..." }
```

**Success response (200):**
```json
{
"success": true,
"message": "Tokens sent successfully",
"txId": "...",
"amount": "10",
"asset": "usdc",
"destination": "0x..."
}
```

**Error responses:**
- `400` — Invalid address or request format
- `429` — Rate limit exceeded
- `500` — Transfer failed
- `503` — Nitronode unavailable or balance insufficient

### `GET /info`

Returns server metadata.

## WebSocket Connection Management

The Nitrolite SDK maintains a persistent WebSocket connection with Nitronode:

- **Connection**: Established on startup inside `nitronode.NewClient()`; no separate connect/auth step is needed
- **Authentication**: Handled internally by the SDK
- **Reconnection**: On each request, `EnsureConnected()` detects a lost connection (via `WaitCh()`) and reconnects with exponential backoff (3 attempts, 300 ms → 600 ms → 2 s)
- **Post-reconnect ping**: Each reconnect attempt is validated with a `Ping` before the new client is accepted
- **Message Handling**: Fully managed by the SDK's internal RPC layer

## Startup Log Example

```
{"level":"info","msg":"Starting Nitrolite Faucet Server","time":"..."}
{"level":"info","msg":"Configuration loaded: Server port=8080, Nitronode URL=wss://nitronode.example.com","time":"..."}
{"level":"debug","msg":"Token 'usdc' is supported by Nitronode","time":"..."}
{"level":"info","msg":"✓ Sufficient usdc balance: 50000000","time":"..."}
{"level":"info","msg":"Successfully connected to Nitronode","time":"..."}
{"level":"info","msg":"Faucet server is ready to serve requests","time":"..."}
```

## Security Features

- **Address Validation**: Validates Ethereum address format before processing
- **Private Key Security**: Private key is only used for signing, never exposed
- **CORS Support**: Configurable CORS headers for web integration
- **Request Signing**: All Nitronode requests are cryptographically signed by the SDK
- **Balance Guard**: Refuses to operate below minimum balance threshold
- **URL Redaction**: `NITRONODE_URL` is never logged in full — only scheme and host are shown

## Building for Production

```bash
go build -o faucet-server main.go
./faucet-server
```

## Development

```bash
# Run tests (from repo root)
go test ./faucet-app/...

# Run with hot reload
go install github.com/cosmtrek/air@latest
air
```

## Error Handling

- **Connection Errors**: Returns 503 if Nitronode is unavailable or reconnection fails
- **Validation Errors**: Returns 400 for invalid addresses or request format
- **Transfer Errors**: Returns 500 for Nitronode transfer failures
- **Service Unavailable**: Returns 503 if token is unsupported or balance is insufficient

## Troubleshooting

**Connection Issues:**

- Verify `NITRONODE_URL` is correct and accessible
- Check firewall settings for WebSocket connections

**Token Not Supported:**

- Verify `TOKEN_SYMBOL` is supported by the Nitronode instance

**Insufficient Balance:**

- Top up the faucet wallet; the server requires at least `MIN_TRANSFER_COUNT × STANDARD_TIP_AMOUNT` available balance
99 changes: 99 additions & 0 deletions faucet-app/server/internal/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package config

import (
"errors"
"fmt"
"net"
"os"
"strings"
"time"

"github.com/ilyakaznacheev/cleanenv"
"github.com/shopspring/decimal"
)

// Config holds all runtime configuration for the faucet server.
type Config struct {
ServerPort string `env:"SERVER_PORT" env-default:"8080" env-description:"HTTP server port"`

OwnerPrivateKey string `env:"OWNER_PRIVATE_KEY" env-required:"true" env-description:"Private key for faucet owner wallet (without 0x prefix)"`
NitronodeURL string `env:"NITRONODE_URL" env-required:"true" env-description:"Nitronode WebSocket URL"`
TokenSymbol string `env:"TOKEN_SYMBOL" env-required:"true" env-description:"Token symbol to distribute (e.g., usdc, weth)"`
StandardTipAmount string `env:"STANDARD_TIP_AMOUNT" env-required:"true" env-description:"Default amount to send per request"`
MinTransferCount int `env:"MIN_TRANSFER_COUNT" env-required:"true" env-description:"Number of transfers a server should have a balance for to operate"`
CooldownPeriod string `env:"COOLDOWN_PERIOD" env-required:"true" env-description:"Cooldown between requests per wallet/IP (e.g. 24h, 1h30m)"`
TrustedProxies string `env:"TRUSTED_PROXIES" env-default:"" env-description:"Comma-separated trusted proxy IPs; empty means direct exposure only"`

LogLevel string `env:"LOG_LEVEL" env-default:"info" env-description:"Logging level (debug, info, warn, error)"`

// Parsed values (set after loading)
StandardTipAmountDecimal decimal.Decimal
CooldownPeriodDuration time.Duration
TrustedProxyList []string
}

// Load reads configuration from a .env file (if present) or environment variables.
// A missing .env file is not an error; any other read or validation failure is.
func Load() (*Config, error) {
var config Config

if _, statErr := os.Stat(".env"); statErr == nil {
if err := cleanenv.ReadConfig(".env", &config); err != nil {
return nil, fmt.Errorf("failed to load .env file: %w", err)
}
} else if !errors.Is(statErr, os.ErrNotExist) {
return nil, fmt.Errorf("failed to stat .env file: %w", statErr)
} else {
if err := cleanenv.ReadEnv(&config); err != nil {
return nil, fmt.Errorf("failed to load configuration from environment: %w", err)
}
}

if err := config.Validate(); err != nil {
return nil, fmt.Errorf("config validation failed: %w", err)
}

return &config, nil
}

// Validate parses and range-checks all fields that require post-load processing.
func (c *Config) Validate() error {
amount, err := decimal.NewFromString(c.StandardTipAmount)
if err != nil {
return fmt.Errorf("STANDARD_TIP_AMOUNT must be a valid decimal number: %w", err)
}
if amount.IsZero() || amount.IsNegative() {
return fmt.Errorf("STANDARD_TIP_AMOUNT must be a positive number")
}
c.StandardTipAmountDecimal = amount

d, err := time.ParseDuration(c.CooldownPeriod)
if err != nil {
return fmt.Errorf("COOLDOWN_PERIOD must be a valid duration (e.g. 24h, 1h30m): %w", err)
}
if d <= 0 {
return fmt.Errorf("COOLDOWN_PERIOD must be positive")
}
c.CooldownPeriodDuration = d

Comment thread
coderabbitai[bot] marked this conversation as resolved.
if c.MinTransferCount <= 0 {
return fmt.Errorf("MIN_TRANSFER_COUNT must be a positive integer")
}

if c.TrustedProxies != "" {
for _, p := range strings.Split(c.TrustedProxies, ",") {
trimmed := strings.TrimSpace(p)
if trimmed == "" {
continue
}
if net.ParseIP(trimmed) == nil {
if _, _, err := net.ParseCIDR(trimmed); err != nil {
return fmt.Errorf("TRUSTED_PROXIES contains invalid IP or CIDR %q: %w", trimmed, err)
}
}
c.TrustedProxyList = append(c.TrustedProxyList, trimmed)
}
}

return nil
}
Loading
Loading