diff --git a/faucet-app/server/.env.example b/faucet-app/server/.env.example new file mode 100644 index 000000000..7b33510b7 --- /dev/null +++ b/faucet-app/server/.env.example @@ -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 diff --git a/faucet-app/server/.gitignore b/faucet-app/server/.gitignore new file mode 100644 index 000000000..4c49bd78f --- /dev/null +++ b/faucet-app/server/.gitignore @@ -0,0 +1 @@ +.env diff --git a/faucet-app/server/Dockerfile b/faucet-app/server/Dockerfile new file mode 100644 index 000000000..7209e87f2 --- /dev/null +++ b/faucet-app/server/Dockerfile @@ -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 + +# 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"] diff --git a/faucet-app/server/README.md b/faucet-app/server/README.md new file mode 100644 index 000000000..1540c441e --- /dev/null +++ b/faucet-app/server/README.md @@ -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 diff --git a/faucet-app/server/internal/config/config.go b/faucet-app/server/internal/config/config.go new file mode 100644 index 000000000..f67ed7ab7 --- /dev/null +++ b/faucet-app/server/internal/config/config.go @@ -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 + + 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 +} diff --git a/faucet-app/server/internal/logger/logger.go b/faucet-app/server/internal/logger/logger.go new file mode 100644 index 000000000..867993cbe --- /dev/null +++ b/faucet-app/server/internal/logger/logger.go @@ -0,0 +1,61 @@ +// Package logger provides a thin structured-logging wrapper around logrus. +package logger + +import ( + "os" + + "github.com/sirupsen/logrus" +) + +// Log is the package-level logger. It is initialised with sane defaults at +// package init time so callers never encounter a nil dereference, even if +// Initialize has not yet been called. +var Log = logrus.New() + +func init() { + Log.SetFormatter(&logrus.JSONFormatter{ + TimestampFormat: "2006-01-02T15:04:05.000Z07:00", + }) + Log.SetOutput(os.Stdout) + Log.SetLevel(logrus.InfoLevel) +} + +// Initialize reconfigures the logger with the requested level. +func Initialize(level string) error { + logLevel, err := logrus.ParseLevel(level) + if err != nil { + return err + } + Log.SetLevel(logLevel) + return nil +} + +// Info logs an info-level message. +func Info(args ...interface{}) { Log.Info(args...) } + +// Infof logs a formatted info-level message. +func Infof(format string, args ...interface{}) { Log.Infof(format, args...) } + +// Warn logs a warn-level message. +func Warn(args ...interface{}) { Log.Warn(args...) } + +// Warnf logs a formatted warn-level message. +func Warnf(format string, args ...interface{}) { Log.Warnf(format, args...) } + +// Error logs an error-level message. +func Error(args ...interface{}) { Log.Error(args...) } + +// Errorf logs a formatted error-level message. +func Errorf(format string, args ...interface{}) { Log.Errorf(format, args...) } + +// Debug logs a debug-level message. +func Debug(args ...interface{}) { Log.Debug(args...) } + +// Debugf logs a formatted debug-level message. +func Debugf(format string, args ...interface{}) { Log.Debugf(format, args...) } + +// Fatal logs a fatal-level message and exits. +func Fatal(args ...interface{}) { Log.Fatal(args...) } + +// Fatalf logs a formatted fatal-level message and exits. +func Fatalf(format string, args ...interface{}) { Log.Fatalf(format, args...) } diff --git a/faucet-app/server/internal/nitronode/client.go b/faucet-app/server/internal/nitronode/client.go new file mode 100644 index 000000000..93232e9b8 --- /dev/null +++ b/faucet-app/server/internal/nitronode/client.go @@ -0,0 +1,321 @@ +package nitronode + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/layer-3/nitrolite/pkg/core" + "github.com/layer-3/nitrolite/pkg/sign" + sdk "github.com/layer-3/nitrolite/sdk/go" + "github.com/shopspring/decimal" + + "github.com/layer-3/nitrolite/faucet-app/server/internal/logger" +) + +const ( + sdkCallTimeout = 30 * time.Second + reconnectInitDelay = 300 * time.Millisecond + reconnectMaxDelay = 2 * time.Second + reconnectAttempts = 3 + pingTimeout = 3 * time.Second +) + +// TransferResult holds the result of a token transfer. +type TransferResult struct { + TxID string + Amount string + Asset string +} + +// Client wraps the Nitrolite SDK client for faucet operations. +type Client struct { + mu sync.RWMutex + sdkClient *sdk.Client + newSDKClient func() (*sdk.Client, error) // captures parsed signers; no raw key hex stored + + reconnectMu sync.Mutex // serialises reconnect attempts; not held during I/O + tokenMu sync.Mutex // serialises GetAssets; prevents N goroutines racing to validate + + ownerAddress string + tokenSymbol string + tipAmount decimal.Decimal + minTransferCount int + tokenSupported bool // cached per connection; reset in reconnect +} + +// NewClient creates a Client that wraps the Nitrolite SDK for faucet operations. +// privateKeyHex drives both message signing and tx signing. nitronodeURL is the +// WebSocket endpoint. The client is immediately connected and ready to use. +func NewClient(privateKeyHex, nitronodeURL, tokenSymbol string, tipAmount decimal.Decimal, minTransferCount int) (*Client, error) { + // Parse signers once — raw key hex is used here and not retained on the struct. + msgSigner, err := sign.NewEthereumMsgSigner(privateKeyHex) + if err != nil { + return nil, fmt.Errorf("failed to create message signer: %w", err) + } + + stateSigner, err := core.NewChannelDefaultSigner(msgSigner) + if err != nil { + return nil, fmt.Errorf("failed to create state signer: %w", err) + } + + txSigner, err := sign.NewEthereumRawSigner(privateKeyHex) + if err != nil { + return nil, fmt.Errorf("failed to create tx signer: %w", err) + } + + // factory captures already-parsed signers so reconnects don't need the raw key. + factory := func() (*sdk.Client, error) { + cl, err := sdk.NewClient(nitronodeURL, stateSigner, txSigner, + sdk.WithApplicationID("faucet"), + sdk.WithErrorHandler(func(err error) { + logger.Errorf("Nitronode connection error: %v", err) + }), + ) + if err != nil { + return nil, fmt.Errorf("failed to connect to Nitronode: %w", err) + } + return cl, nil + } + + sdkClient, err := factory() + if err != nil { + return nil, err + } + + return &Client{ + sdkClient: sdkClient, + newSDKClient: factory, + ownerAddress: sdkClient.GetUserAddress(), // immutable: all factory clients share the same signer + tokenSymbol: tokenSymbol, + tipAmount: tipAmount, + minTransferCount: minTransferCount, + }, nil +} + +// GetOwnerAddress returns the faucet owner's Ethereum address. +// Derived from the immutable raw signer; cached at construction so no lock is needed. +func (c *Client) GetOwnerAddress() string { + return c.ownerAddress +} + +// EnsureConnected checks the connection and reconnects with exponential backoff if necessary. +func (c *Client) EnsureConnected() error { + // Fast path: check WaitCh under read lock. + c.mu.RLock() + waitCh := c.sdkClient.WaitCh() + c.mu.RUnlock() + + select { + case <-waitCh: + // Connection lost; fall through. + default: + return nil + } + + // Serialise reconnect attempts; only one goroutine does the work at a time. + c.reconnectMu.Lock() + defer c.reconnectMu.Unlock() + + // Double-check under read lock — another goroutine may have reconnected while + // we waited for reconnectMu. + c.mu.RLock() + waitCh = c.sdkClient.WaitCh() + c.mu.RUnlock() + + select { + case <-waitCh: + // Still disconnected; proceed. + default: + return nil + } + + return c.reconnect() +} + +// reconnect retries SDK connection with exponential backoff. +// reconnectMu must be held by the caller; c.mu is NOT held here so I/O +// (dial + ping) does not stall readers or writers. +func (c *Client) reconnect() error { + delay := reconnectInitDelay + var lastErr error + + for attempt := 1; attempt <= reconnectAttempts; attempt++ { + logger.Infof("Reconnecting to Nitronode (attempt %d/%d)...", attempt, reconnectAttempts) + + newClient, err := c.newSDKClient() + if err != nil { + lastErr = err + logger.Warnf("Reconnect attempt %d/%d failed: %v", attempt, reconnectAttempts, err) + } else { + // Ping without holding any lock. + ctx, cancel := context.WithTimeout(context.Background(), pingTimeout) + pingErr := newClient.Ping(ctx) + cancel() + + if pingErr == nil { + // Swap under write lock — fast, no I/O. + c.mu.Lock() + old := c.sdkClient + c.sdkClient = newClient + c.tokenSupported = false // re-validate on new connection + c.mu.Unlock() + + // Close old client outside the lock. + if err := old.Close(); err != nil { + logger.Errorf("Error closing stale Nitronode client: %v", err) + } + logger.Infof("Successfully reconnected to Nitronode on attempt %d", attempt) + return nil + } + + lastErr = fmt.Errorf("ping failed: %w", pingErr) + logger.Warnf("Reconnect attempt %d/%d ping failed: %v", attempt, reconnectAttempts, pingErr) + if err := newClient.Close(); err != nil { + logger.Warnf("Error closing failed reconnect client: %v", err) + } + } + + if attempt < reconnectAttempts { + time.Sleep(delay) + delay = min(delay*2, reconnectMaxDelay) + } + } + + return fmt.Errorf("failed to reconnect after %d attempts: %w", reconnectAttempts, lastErr) +} + +// EnsureOperational validates token support and sufficient balance. +func (c *Client) EnsureOperational() error { + if err := c.validateTokenSupport(c.tokenSymbol); err != nil { + return fmt.Errorf("token validation failed: %w", err) + } + + if err := c.validateFaucetBalance(c.tokenSymbol, c.tipAmount, c.minTransferCount); err != nil { + return fmt.Errorf("balance check failed: %w", err) + } + + return nil +} + +func (c *Client) validateTokenSupport(tokenSymbol string) error { + // Fast path: already confirmed for this connection. + c.mu.RLock() + if c.tokenSupported { + c.mu.RUnlock() + return nil + } + c.mu.RUnlock() + + // Serialise the GetAssets call so only one goroutine fetches at a time. + c.tokenMu.Lock() + defer c.tokenMu.Unlock() + + // Double-check after acquiring tokenMu. + c.mu.RLock() + cached := c.tokenSupported + cl := c.sdkClient + c.mu.RUnlock() + + if cached { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), sdkCallTimeout) + defer cancel() + + assets, err := cl.GetAssets(ctx, nil) + if err != nil { + return fmt.Errorf("failed to fetch supported assets: %w", err) + } + + for _, asset := range assets { + if strings.EqualFold(asset.Symbol, tokenSymbol) { + logger.Debugf("Token '%s' is supported by Nitronode", tokenSymbol) + c.mu.Lock() + if c.sdkClient == cl { // guard against reconnect between fetch and write + c.tokenSupported = true + } + c.mu.Unlock() + return nil + } + } + + return fmt.Errorf("token '%s' is not supported by Nitronode", tokenSymbol) +} + +func (c *Client) validateFaucetBalance(tokenSymbol string, tipAmount decimal.Decimal, minTransferCount int) error { + ctx, cancel := context.WithTimeout(context.Background(), sdkCallTimeout) + defer cancel() + + c.mu.RLock() + cl := c.sdkClient + c.mu.RUnlock() + + ownerAddress := cl.GetUserAddress() + + balances, err := cl.GetBalances(ctx, ownerAddress) + if err != nil { + return fmt.Errorf("failed to fetch faucet balance: %w", err) + } + + minRequired := tipAmount.Mul(decimal.NewFromInt(int64(minTransferCount))) + + for _, balance := range balances { + if strings.EqualFold(balance.Asset, tokenSymbol) { + if balance.Balance.LessThan(minRequired) { + return fmt.Errorf("insufficient %s balance: %s (required: %s for %d transfers)", + tokenSymbol, balance.Balance.String(), minRequired.String(), minTransferCount) + } + logger.Infof("✓ Sufficient %s balance: %s", tokenSymbol, balance.Balance.String()) + if balance.Enforced.IsPositive() && balance.Enforced.LessThan(balance.Balance) { + logger.Warnf("⚠ %s enforced balance (%s) is below channel balance (%s); consider checkpointing", + tokenSymbol, balance.Enforced.String(), balance.Balance.String()) + } + return nil + } + } + + return fmt.Errorf("insufficient %s balance: 0 (required: %s for %d transfers)", + tokenSymbol, minRequired.String(), minTransferCount) +} + +// Transfer sends tokens to the destination address. +func (c *Client) Transfer(destination, asset string, amount decimal.Decimal) (*TransferResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), sdkCallTimeout) + defer cancel() + + c.mu.RLock() + cl := c.sdkClient + c.mu.RUnlock() + + state, err := cl.Transfer(ctx, destination, asset, amount) + if err != nil { + return nil, fmt.Errorf("transfer failed: %w", err) + } + + result := &TransferResult{ + TxID: state.Transition.TxID, + Amount: state.Transition.Amount.String(), + Asset: state.Asset, + } + + if result.Amount == "" { + result.Amount = amount.String() + } + if result.Asset == "" { + result.Asset = asset + } + + return result, nil +} + +// Close shuts down the Nitronode connection. +// Uses write lock to serialise with reconnect and prevent closing a freshly installed client. +func (c *Client) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + return c.sdkClient.Close() +} diff --git a/faucet-app/server/internal/nitronode/client_validation_test.go b/faucet-app/server/internal/nitronode/client_validation_test.go new file mode 100644 index 000000000..6bd8a5ea0 --- /dev/null +++ b/faucet-app/server/internal/nitronode/client_validation_test.go @@ -0,0 +1,39 @@ +package nitronode + +import ( + "testing" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewClientValidation(t *testing.T) { + t.Run("should fail with invalid private key", func(t *testing.T) { + client, err := NewClient("not-a-valid-key", "ws://localhost:8080", "usdc", decimal.NewFromInt(10), 1) + + assert.Nil(t, client) + require.Error(t, err) + }) + + t.Run("should fail with invalid URL", func(t *testing.T) { + validKey := "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + + client, err := NewClient(validKey, "ws://localhost:19999", "usdc", decimal.NewFromInt(10), 1) + + assert.Nil(t, client) + require.Error(t, err) + }) + + t.Run("should handle 0x prefixed key", func(t *testing.T) { + // NewEthereumMsgSigner should handle 0x prefix; if it fails, err is non-nil. + // Either outcome is acceptable — the test just asserts no panic and consistency. + key := "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + + client, err := NewClient(key, "ws://localhost:19999", "usdc", decimal.NewFromInt(10), 1) + if err == nil { + require.NotNil(t, client) + require.NoError(t, client.Close()) + } + }) +} diff --git a/faucet-app/server/internal/server/ratelimiter.go b/faucet-app/server/internal/server/ratelimiter.go new file mode 100644 index 000000000..854544635 --- /dev/null +++ b/faucet-app/server/internal/server/ratelimiter.go @@ -0,0 +1,74 @@ +package server + +import ( + "sync" + "time" +) + +type rateLimiter struct { + mu sync.Mutex + cooldown time.Duration + seen map[string]time.Time + calls uint64 +} + +func newRateLimiter(cooldown time.Duration) *rateLimiter { + return &rateLimiter{ + cooldown: cooldown, + seen: make(map[string]time.Time), + } +} + +// checkAndRecord atomically checks if key is allowed and, if so, records the attempt. +// Returns true if allowed (cooldown slot consumed), false if on cooldown. +func (r *rateLimiter) checkAndRecord(key string) bool { + now := time.Now() + r.mu.Lock() + defer r.mu.Unlock() + r.calls++ + r.evictExpiredLocked(now) + last, exists := r.seen[key] + if exists && now.Sub(last) < r.cooldown { + return false + } + r.seen[key] = now + return true +} + +// checkAndRecordBoth atomically checks both addr and ip. Only records both if +// both pass — prevents a blocked IP from burning the wallet's cooldown slot. +// Returns (false, "address") or (false, "ip") if either is blocked. +func (r *rateLimiter) checkAndRecordBoth(addr, ip string) (bool, string) { + now := time.Now() + r.mu.Lock() + defer r.mu.Unlock() + + r.calls++ + r.evictExpiredLocked(now) + + if last, ok := r.seen[addr]; ok && now.Sub(last) < r.cooldown { + return false, "address" + } + if last, ok := r.seen[ip]; ok && now.Sub(last) < r.cooldown { + return false, "ip" + } + + r.seen[addr] = now + if ip != addr { + r.seen[ip] = now + } + return true, "" +} + +// evictExpiredLocked removes entries that are past the cooldown window. +// Must be called with r.mu held. Runs every 1024 calls to amortise cost. +func (r *rateLimiter) evictExpiredLocked(now time.Time) { + if r.calls%1024 != 0 { + return + } + for k, ts := range r.seen { + if now.Sub(ts) >= r.cooldown { + delete(r.seen, k) + } + } +} diff --git a/faucet-app/server/internal/server/server.go b/faucet-app/server/internal/server/server.go new file mode 100644 index 000000000..a56a07728 --- /dev/null +++ b/faucet-app/server/internal/server/server.go @@ -0,0 +1,234 @@ +package server + +import ( + "net/http" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" + + "github.com/layer-3/nitrolite/faucet-app/server/internal/config" + "github.com/layer-3/nitrolite/faucet-app/server/internal/logger" + "github.com/layer-3/nitrolite/faucet-app/server/internal/nitronode" +) + +// NitronodeClient is the interface the server uses to interact with Nitronode. +type NitronodeClient interface { + GetOwnerAddress() string + EnsureConnected() error + EnsureOperational() error + Transfer(destination, asset string, amount decimal.Decimal) (*nitronode.TransferResult, error) +} + +// Error message constants +const ( + ErrInvalidRequestFormat = "Invalid request format. Expected JSON with 'userAddress' field." + ErrInvalidAddressFormat = "Invalid address format." + ErrNitronodeConnectionFailed = "Failed to connect to Nitronode." + ErrServiceUnavailable = "Faucet service is currently unavailable." + ErrTransferFailed = "Failed to send tokens." + ErrRateLimitExceeded = "Rate limit exceeded. Please try again later." + MsgTokensSentSuccessfully = "Tokens sent successfully" +) + +type Server struct { + config *config.Config + nitronodeClient NitronodeClient + router *gin.Engine + rateLimiter *rateLimiter +} + +type FaucetRequest struct { + UserAddress string `json:"userAddress" binding:"required"` +} + +type FaucetResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + TxID string `json:"txId,omitempty"` + Amount string `json:"amount,omitempty"` + Asset string `json:"asset,omitempty"` + Destination string `json:"destination,omitempty"` +} + +type ErrorResponse struct { + Error string `json:"error"` +} + +func NewServer(cfg *config.Config, client NitronodeClient) *Server { + if cfg.LogLevel == "debug" { + gin.SetMode(gin.DebugMode) + } else { + gin.SetMode(gin.ReleaseMode) + } + + router := gin.New() + if len(cfg.TrustedProxyList) > 0 { + router.SetTrustedProxies(cfg.TrustedProxyList) + } else { + // No proxies configured: c.ClientIP() uses RemoteAddr directly. + // Set TRUSTED_PROXIES if the faucet is behind an ingress or load balancer, + // otherwise IP-based rate limiting will collapse to one bucket per proxy. + router.SetTrustedProxies(nil) + } + + // Add middleware + router.Use(gin.Recovery()) + router.Use(requestLogger()) + router.Use(corsMiddleware()) + + server := &Server{ + config: cfg, + nitronodeClient: client, + router: router, + rateLimiter: newRateLimiter(cfg.CooldownPeriodDuration), + } + + server.setupRoutes() + return server +} + +func (s *Server) setupRoutes() { + s.router.POST("/requestTokens", s.requestTokens) + s.router.GET("/info", s.getInfo) +} + +func (s *Server) getInfo(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "service": "Nitrolite Faucet Server", + "version": "1.0.0", + "faucet_address": s.nitronodeClient.GetOwnerAddress(), + "standard_tip_amount": s.config.StandardTipAmountDecimal.String(), + "token_symbol": s.config.TokenSymbol, + "endpoints": []string{"/requestTokens"}, + }) +} + +func (s *Server) requestTokens(c *gin.Context) { + var req FaucetRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.Warnf("Invalid request format: %v", err) + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: ErrInvalidRequestFormat, + }) + return + } + + // Validate the user address + userAddress := strings.TrimSpace(req.UserAddress) + if !common.IsHexAddress(userAddress) { + logger.Warnf("Invalid address format: %s", userAddress) + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: ErrInvalidAddressFormat, + }) + return + } + + userAddress = common.HexToAddress(userAddress).Hex() + + // Atomically check-and-record both keys under one lock. This prevents a + // blocked IP from burning the wallet's cooldown slot and eliminates TOCTOU. + // Every accepted request (including ones that later fail) consumes a slot, + // preventing unlimited probing via induced failures. + clientIP := c.ClientIP() + if allowed, blocked := s.rateLimiter.checkAndRecordBoth(userAddress, clientIP); !allowed { + if blocked == "address" { + logger.Warnf("Rate limit exceeded for address %s", userAddress) + } else { + logger.Warnf("Rate limit exceeded for IP %s (address: %s)", clientIP, userAddress) + } + c.JSON(http.StatusTooManyRequests, ErrorResponse{Error: ErrRateLimitExceeded}) + return + } + + logger.Infof("Processing faucet request for address: %s", userAddress) + + // Ensure client is connected + if err := s.nitronodeClient.EnsureConnected(); err != nil { + logger.Errorf("Connection failed for %s: %v", userAddress, err) + c.JSON(http.StatusServiceUnavailable, ErrorResponse{ + Error: ErrNitronodeConnectionFailed, + }) + return + } + + // Ensure client is operational + if err := s.nitronodeClient.EnsureOperational(); err != nil { + logger.Errorf("Service not operational for %s: %v", userAddress, err) + c.JSON(http.StatusServiceUnavailable, ErrorResponse{ + Error: ErrServiceUnavailable, + }) + return + } + + // Perform the transfer + result, err := s.nitronodeClient.Transfer( + userAddress, + s.config.TokenSymbol, + s.config.StandardTipAmountDecimal, + ) + if err != nil { + logger.Errorf("Transfer failed for %s: %v", userAddress, err) + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: ErrTransferFailed, + }) + return + } + if result == nil { + logger.Errorf("Transfer returned nil result for %s", userAddress) + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: ErrTransferFailed, + }) + return + } + + txID := result.TxID + amount := result.Amount + asset := result.Asset + + logger.Infof("Successfully sent %s %s to %s (txID: %s)", + amount, asset, userAddress, txID) + + c.JSON(http.StatusOK, FaucetResponse{ + Success: true, + Message: MsgTokensSentSuccessfully, + TxID: txID, + Amount: amount, + Asset: asset, + Destination: userAddress, + }) +} + +func (s *Server) Start() error { + addr := ":" + s.config.ServerPort + logger.Infof("Starting HTTP server on port %s", s.config.ServerPort) + return s.router.Run(addr) +} + +// Middleware functions + +func requestLogger() gin.HandlerFunc { + return func(c *gin.Context) { + // Log request + logger.Debugf("%s %s from %s", c.Request.Method, c.Request.URL.Path, c.ClientIP()) + c.Next() + // Log response status + logger.Debugf("%s %s - %d", c.Request.Method, c.Request.URL.Path, c.Writer.Status()) + } +} + +func corsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} diff --git a/faucet-app/server/internal/server/server_test.go b/faucet-app/server/internal/server/server_test.go new file mode 100644 index 000000000..7a400b4e9 --- /dev/null +++ b/faucet-app/server/internal/server/server_test.go @@ -0,0 +1,321 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/layer-3/nitrolite/faucet-app/server/internal/config" + "github.com/layer-3/nitrolite/faucet-app/server/internal/logger" + "github.com/layer-3/nitrolite/faucet-app/server/internal/nitronode" +) + +// mockNitronodeClient is a simple in-memory mock implementing NitronodeClient. +type mockNitronodeClient struct { + ownerAddress string + connErr error + operationalErr error + transferResult *nitronode.TransferResult + transferErr error + capturedDest string + capturedAsset string + capturedAmount decimal.Decimal +} + +func (m *mockNitronodeClient) GetOwnerAddress() string { return m.ownerAddress } +func (m *mockNitronodeClient) EnsureConnected() error { return m.connErr } +func (m *mockNitronodeClient) EnsureOperational() error { return m.operationalErr } +func (m *mockNitronodeClient) Transfer(dest, asset string, amount decimal.Decimal) (*nitronode.TransferResult, error) { + m.capturedDest = dest + m.capturedAsset = asset + m.capturedAmount = amount + return m.transferResult, m.transferErr +} + +func defaultConfig() *config.Config { + return &config.Config{ + ServerPort: "0", + OwnerPrivateKey: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + NitronodeURL: "ws://localhost:0", + TokenSymbol: "usdc", + StandardTipAmount: "10", + StandardTipAmountDecimal: decimal.RequireFromString("10"), + CooldownPeriod: "24h", + CooldownPeriodDuration: 24 * time.Hour, + LogLevel: "debug", + } +} + +func defaultMock() *mockNitronodeClient { + return &mockNitronodeClient{ + ownerAddress: "0x9fc51BEE23Fb53569c46CcF013400f0E19524bd2", + transferResult: &nitronode.TransferResult{ + TxID: "tx-abc123", + Amount: "10", + Asset: "usdc", + }, + } +} + +func TestMain(m *testing.M) { + if err := logger.Initialize("debug"); err != nil { + panic(err) + } + os.Exit(m.Run()) +} + +func TestRequestTokens_Success(t *testing.T) { + mock := defaultMock() + srv := NewServer(defaultConfig(), mock) + + testAddress := common.HexToAddress("0x742D35CC6634c0532925a3B8c17D18fBe3b78890").Hex() + body, err := json.Marshal(FaucetRequest{UserAddress: testAddress}) + require.NoError(t, err) + + req := httptest.NewRequest("POST", "/requestTokens", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp FaucetResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.True(t, resp.Success) + assert.Equal(t, MsgTokensSentSuccessfully, resp.Message) + assert.Equal(t, "tx-abc123", resp.TxID) + assert.Equal(t, "10", resp.Amount) + assert.Equal(t, "usdc", resp.Asset) + assert.Equal(t, testAddress, resp.Destination) + + assert.Equal(t, testAddress, mock.capturedDest) + assert.Equal(t, "usdc", mock.capturedAsset) + assert.True(t, decimal.RequireFromString("10").Equal(mock.capturedAmount)) +} + +func TestRequestTokens_InvalidAddress(t *testing.T) { + srv := NewServer(defaultConfig(), defaultMock()) + + body, err := json.Marshal(FaucetRequest{UserAddress: "not-an-address"}) + require.NoError(t, err) + req := httptest.NewRequest("POST", "/requestTokens", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + var resp ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, ErrInvalidAddressFormat, resp.Error) +} + +func TestRequestTokens_MissingField(t *testing.T) { + srv := NewServer(defaultConfig(), defaultMock()) + + body, err := json.Marshal(map[string]string{"wrongField": "0x1234"}) + require.NoError(t, err) + req := httptest.NewRequest("POST", "/requestTokens", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + var resp ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, ErrInvalidRequestFormat, resp.Error) +} + +func TestRequestTokens_ConnectionFailure(t *testing.T) { + mock := defaultMock() + mock.connErr = assert.AnError + srv := NewServer(defaultConfig(), mock) + + body, err := json.Marshal(FaucetRequest{UserAddress: "0x742d35Cc6634C0532925a3b8c17d18fBE3b78890"}) + require.NoError(t, err) + req := httptest.NewRequest("POST", "/requestTokens", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + var resp ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, ErrNitronodeConnectionFailed, resp.Error) +} + +func TestRequestTokens_OperationalFailure(t *testing.T) { + mock := defaultMock() + mock.operationalErr = assert.AnError + srv := NewServer(defaultConfig(), mock) + + body, err := json.Marshal(FaucetRequest{UserAddress: "0x742d35Cc6634C0532925a3b8c17d18fBE3b78890"}) + require.NoError(t, err) + req := httptest.NewRequest("POST", "/requestTokens", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + var resp ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, ErrServiceUnavailable, resp.Error) +} + +func TestRequestTokens_TransferFailure(t *testing.T) { + mock := defaultMock() + mock.transferResult = nil + mock.transferErr = assert.AnError + srv := NewServer(defaultConfig(), mock) + + body, err := json.Marshal(FaucetRequest{UserAddress: "0x742d35Cc6634C0532925a3b8c17d18fBE3b78890"}) + require.NoError(t, err) + req := httptest.NewRequest("POST", "/requestTokens", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + var resp ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, ErrTransferFailed, resp.Error) +} + +func TestRateLimiting(t *testing.T) { + cfg := defaultConfig() + cfg.CooldownPeriodDuration = 24 * time.Hour + + t.Run("second request from same wallet is rejected", func(t *testing.T) { + mock := defaultMock() + srv := NewServer(cfg, mock) + + testAddress := common.HexToAddress("0x742D35CC6634c0532925a3B8c17D18fBe3b78890").Hex() + body, err := json.Marshal(FaucetRequest{UserAddress: testAddress}) + require.NoError(t, err) + + // First request — should succeed + req1 := httptest.NewRequest("POST", "/requestTokens", bytes.NewReader(body)) + req1.Header.Set("Content-Type", "application/json") + w1 := httptest.NewRecorder() + srv.router.ServeHTTP(w1, req1) + assert.Equal(t, http.StatusOK, w1.Code) + + // Second request same wallet — should be rate limited + req2 := httptest.NewRequest("POST", "/requestTokens", bytes.NewReader(body)) + req2.Header.Set("Content-Type", "application/json") + w2 := httptest.NewRecorder() + srv.router.ServeHTTP(w2, req2) + assert.Equal(t, http.StatusTooManyRequests, w2.Code) + + var resp ErrorResponse + require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &resp)) + assert.Equal(t, ErrRateLimitExceeded, resp.Error) + }) + + t.Run("failed transfer consumes rate limit slot", func(t *testing.T) { + mock := defaultMock() + mock.transferResult = nil + mock.transferErr = assert.AnError + srv := NewServer(cfg, mock) + + testAddress := common.HexToAddress("0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF").Hex() + body, err := json.Marshal(FaucetRequest{UserAddress: testAddress}) + require.NoError(t, err) + + // First request fails at transfer but still consumes the rate-limit slot. + req1 := httptest.NewRequest("POST", "/requestTokens", bytes.NewReader(body)) + req1.Header.Set("Content-Type", "application/json") + w1 := httptest.NewRecorder() + srv.router.ServeHTTP(w1, req1) + assert.Equal(t, http.StatusInternalServerError, w1.Code) + + // Second request is rate-limited because the slot was consumed on the first attempt. + req2 := httptest.NewRequest("POST", "/requestTokens", bytes.NewReader(body)) + req2.Header.Set("Content-Type", "application/json") + w2 := httptest.NewRecorder() + srv.router.ServeHTTP(w2, req2) + assert.Equal(t, http.StatusTooManyRequests, w2.Code) + }) + + t.Run("different wallets from different IPs are not rate limited by each other", func(t *testing.T) { + mock := defaultMock() + srv := NewServer(cfg, mock) + + addr1 := common.HexToAddress("0x742D35CC6634c0532925a3B8c17D18fBe3b78890").Hex() + addr2 := common.HexToAddress("0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF").Hex() + + body1, _ := json.Marshal(FaucetRequest{UserAddress: addr1}) + body2, _ := json.Marshal(FaucetRequest{UserAddress: addr2}) + + req1 := httptest.NewRequest("POST", "/requestTokens", bytes.NewReader(body1)) + req1.Header.Set("Content-Type", "application/json") + req1.RemoteAddr = "10.0.0.1:1234" + w1 := httptest.NewRecorder() + srv.router.ServeHTTP(w1, req1) + assert.Equal(t, http.StatusOK, w1.Code) + + req2 := httptest.NewRequest("POST", "/requestTokens", bytes.NewReader(body2)) + req2.Header.Set("Content-Type", "application/json") + req2.RemoteAddr = "10.0.0.2:1234" + w2 := httptest.NewRecorder() + srv.router.ServeHTTP(w2, req2) + assert.Equal(t, http.StatusOK, w2.Code) + }) + + t.Run("same IP with different wallet is still rate limited", func(t *testing.T) { + mock := defaultMock() + srv := NewServer(cfg, mock) + + addr1 := common.HexToAddress("0x742D35CC6634c0532925a3B8c17D18fBe3b78890").Hex() + addr2 := common.HexToAddress("0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF").Hex() + + body1, _ := json.Marshal(FaucetRequest{UserAddress: addr1}) + body2, _ := json.Marshal(FaucetRequest{UserAddress: addr2}) + + req1 := httptest.NewRequest("POST", "/requestTokens", bytes.NewReader(body1)) + req1.Header.Set("Content-Type", "application/json") + w1 := httptest.NewRecorder() + srv.router.ServeHTTP(w1, req1) + assert.Equal(t, http.StatusOK, w1.Code) + + // Different wallet, same IP — should be blocked by IP limit + req2 := httptest.NewRequest("POST", "/requestTokens", bytes.NewReader(body2)) + req2.Header.Set("Content-Type", "application/json") + w2 := httptest.NewRecorder() + srv.router.ServeHTTP(w2, req2) + assert.Equal(t, http.StatusTooManyRequests, w2.Code) + }) +} + +func TestInfoEndpoint(t *testing.T) { + mock := defaultMock() + srv := NewServer(defaultConfig(), mock) + + req := httptest.NewRequest("GET", "/info", nil) + w := httptest.NewRecorder() + + srv.router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "Nitrolite Faucet Server", resp["service"]) + assert.Equal(t, "1.0.0", resp["version"]) + assert.Equal(t, mock.ownerAddress, resp["faucet_address"]) + assert.Equal(t, "10", resp["standard_tip_amount"]) + assert.Equal(t, "usdc", resp["token_symbol"]) +} diff --git a/faucet-app/server/main.go b/faucet-app/server/main.go new file mode 100644 index 000000000..7402244fe --- /dev/null +++ b/faucet-app/server/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "net/url" + "os" + "os/signal" + "syscall" + + "github.com/layer-3/nitrolite/faucet-app/server/internal/config" + "github.com/layer-3/nitrolite/faucet-app/server/internal/logger" + "github.com/layer-3/nitrolite/faucet-app/server/internal/nitronode" + "github.com/layer-3/nitrolite/faucet-app/server/internal/server" +) + +func redactURL(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return "[invalid URL]" + } + return fmt.Sprintf("%s://%s", u.Scheme, u.Host) +} + +func main() { + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err) + os.Exit(1) + } + + if err := logger.Initialize(cfg.LogLevel); err != nil { + fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err) + os.Exit(1) + } + + logger.Info("Starting Nitrolite Faucet Server") + logger.Infof("Configuration loaded: Server port=%s, Nitronode URL=%s", + cfg.ServerPort, redactURL(cfg.NitronodeURL)) + + client, err := nitronode.NewClient(cfg.OwnerPrivateKey, cfg.NitronodeURL, cfg.TokenSymbol, cfg.StandardTipAmountDecimal, cfg.MinTransferCount) + if err != nil { + logger.Fatalf("Failed to create Nitronode client: %v", err) + } + + if err := client.EnsureOperational(); err != nil { + logger.Fatalf("Operational check failed: %v", err) + } + + logger.Infof("Faucet owner address: %s", client.GetOwnerAddress()) + logger.Info("Successfully connected to Nitronode") + + httpServer := server.NewServer(cfg, client) + + go func() { + if err := httpServer.Start(); err != nil { + logger.Fatalf("Failed to start HTTP server: %v", err) + } + }() + + logger.Info("Faucet server is ready to serve requests") + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + logger.Info("Shutting down server...") + + if err := client.Close(); err != nil { + logger.Errorf("Error closing Nitronode connection: %v", err) + } + + logger.Info("Server shutdown complete") +} diff --git a/go.mod b/go.mod index 2350609e0..7e1771147 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,15 @@ require ( cloud.google.com/go/kms v1.30.0 github.com/c-bata/go-prompt v0.2.6 github.com/ethereum/go-ethereum v1.17.2 + github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/ilyakaznacheev/cleanenv v1.5.0 github.com/joho/godotenv v1.5.1 github.com/jsternberg/zap-logfmt v1.3.0 github.com/prometheus/client_golang v1.23.2 + github.com/shopspring/decimal v1.4.0 + github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 @@ -30,20 +34,43 @@ require ( cloud.google.com/go/iam v1.7.0 // indirect cloud.google.com/go/longrunning v0.9.0 // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.4 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect github.com/googleapis/gax-go/v2 v2.22.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-tty v0.0.3 // indirect github.com/moby/moby/api v1.54.2 // indirect github.com/moby/moby/client v0.4.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/term v1.2.0-beta.2 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect github.com/stretchr/objx v0.5.3 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.43.0 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect @@ -81,7 +108,6 @@ require ( github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/holiman/uint256 v1.3.2 // indirect - github.com/ilyakaznacheev/cleanenv v1.5.0 github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.9.2 // indirect @@ -90,7 +116,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/jmoiron/sqlx v1.4.0 github.com/klauspost/compress v1.18.5 // indirect - github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.13 // indirect @@ -120,8 +146,6 @@ require ( github.com/sethvargo/go-retry v0.3.0 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect - github.com/shopspring/decimal v1.4.0 - github.com/sirupsen/logrus v1.9.4 // indirect github.com/supranational/blst v0.3.16 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect diff --git a/go.sum b/go.sum index 6b994e395..c0082ae6a 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,10 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/c-bata/go-prompt v0.2.6 h1:POP+nrHE+DfLYx370bedwNhsqmpCUynWPxuHi0C5vZI= github.com/c-bata/go-prompt v0.2.6/go.mod h1:/LMAke8wD2FsNu9EXNdHxNLbd9MedkPnCdfpU9wwHfY= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -44,6 +48,8 @@ github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= @@ -118,10 +124,16 @@ github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeD github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -130,10 +142,22 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= +github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -150,6 +174,7 @@ github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -204,12 +229,14 @@ github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jsternberg/zap-logfmt v1.3.0 h1:z1n1AOHVVydOOVuyphbOKyR4NICDQFiJMn1IK5hVQ5Y= github.com/jsternberg/zap-logfmt v1.3.0/go.mod h1:N3DENp9WNmCZxvkBD/eReWwz1149BK6jEN9cQ4fNwZE= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= -github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -218,6 +245,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= @@ -273,6 +302,11 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -283,6 +317,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7 h1:oYW+YCJ1pachXTQmzR3rNLYGGz4g/UgFcjb28p/viDM= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0= github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= @@ -315,6 +351,10 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -338,10 +378,15 @@ github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+D github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE= @@ -356,6 +401,10 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= @@ -380,6 +429,8 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= @@ -388,10 +439,14 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -409,7 +464,6 @@ golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= @@ -420,6 +474,8 @@ golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.277.0 h1:HJfyJUiNeBBUMai7ez8u14wkp/gH/I4wpGbbO9o+cSk=