From 74693c933869ac4c611b77216dbcaacb7045bbb5 Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 2 Jul 2026 12:07:45 +0200 Subject: [PATCH 1/3] feat(evm): mine + claim test ETH via evm.faucet evm.faucet(network, address) previously returned a browser URL, which an agent cannot use (no browser, captcha, or WebSocket). It now runs the full PoWFaucet agent-REST flow server-side and returns the claim transaction hash. - pkg/faucet: Go mining core using golang.org/x/crypto/argon2 (Argon2id). Zero external/runtime deps, so it works on any OS panda ships to. - evm.faucet server operation: auth-gated (requires 'panda auth'), resolves the network's faucet URL from Cartographoor, delegates to pkg/faucet. - sandbox evm.faucet() delegates to the operation and returns the tx hash. Requires the network's faucet to run a PoWFaucet build with the agent REST API (Argon2id/v19). Tests: mining core against a faithful mock + auth gate; faucet_live_test.go is an env-guarded live e2e. --- go.mod | 2 +- modules/evm/examples.yaml | 10 +- modules/evm/module.go | 38 +--- modules/evm/python/evm.py | 29 ++- pkg/faucet/faucet.go | 340 ++++++++++++++++++++++++++++++ pkg/faucet/faucet_live_test.go | 36 ++++ pkg/faucet/faucet_test.go | 186 ++++++++++++++++ pkg/server/operations_dispatch.go | 1 + pkg/server/operations_evm.go | 94 +++++++++ pkg/server/operations_evm_test.go | 79 +++++++ 10 files changed, 760 insertions(+), 55 deletions(-) create mode 100644 pkg/faucet/faucet.go create mode 100644 pkg/faucet/faucet_live_test.go create mode 100644 pkg/faucet/faucet_test.go create mode 100644 pkg/server/operations_evm.go create mode 100644 pkg/server/operations_evm_test.go diff --git a/go.mod b/go.mod index fee75797..4d73312d 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.42.0 go.yaml.in/yaml/v3 v3.0.4 + golang.org/x/crypto v0.51.0 golang.org/x/sys v0.45.0 golang.org/x/time v0.15.0 google.golang.org/protobuf v1.36.11 @@ -132,7 +133,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.55.0 // indirect diff --git a/modules/evm/examples.yaml b/modules/evm/examples.yaml index 34bb2bdf..feb02515 100644 --- a/modules/evm/examples.yaml +++ b/modules/evm/examples.yaml @@ -117,9 +117,9 @@ evm_system_contracts: ]) w = evm.wallet() - print("Fund this address first:", evm.faucet(network, w["address"])) - # after funding: - # txhash = evm.tx(network, instance, w["private_key"], to=None, data=selfdestruct_code) + # faucet() mines + claims and returns the tx hash once the funds land + print("Funded via faucet, tx:", evm.faucet(network, w["address"])) + txhash = evm.tx(network, instance, w["private_key"], to=None, data=selfdestruct_code) evm_deploy_and_fuzz: name: Deploy and Fuzz @@ -135,9 +135,9 @@ evm_deploy_and_fuzz: w = evm.wallet() print("Address:", w["address"]) - print("Fund at:", evm.faucet(network, w["address"])) + print("Funded via faucet, tx:", evm.faucet(network, w["address"])) - # After funding, deploy bytecode that LOGs a result (visible in receipt) + # Funds have landed; deploy bytecode that LOGs a result (visible in receipt) bytecode = evm.assemble([ "PUSH1", 0x42, # value to log "PUSH1", 0x00, # memory offset diff --git a/modules/evm/module.go b/modules/evm/module.go index e870d511..aec0b4f6 100644 --- a/modules/evm/module.go +++ b/modules/evm/module.go @@ -2,11 +2,9 @@ package evm import ( "context" - "encoding/json" "maps" "sync" - "github.com/ethpandaops/panda/pkg/cartographoor" "github.com/ethpandaops/panda/pkg/module" "github.com/ethpandaops/panda/pkg/types" ) @@ -15,7 +13,6 @@ import ( var ( _ module.Module = (*Module)(nil) _ module.ProxyDiscoverable = (*Module)(nil) - _ module.CartographoorAware = (*Module)(nil) _ module.SandboxEnvProvider = (*Module)(nil) _ module.ExamplesProvider = (*Module)(nil) _ module.PythonAPIDocsProvider = (*Module)(nil) @@ -28,8 +25,6 @@ var ( type Module struct { dsMu sync.RWMutex datasources []types.DatasourceInfo - - cartographoorClient cartographoor.CartographoorClient } // New creates a new evm module. @@ -60,11 +55,6 @@ func (m *Module) InitFromDiscovery(datasources []types.DatasourceInfo) error { return nil } -// SetCartographoorClient implements module.CartographoorAware. -func (m *Module) SetCartographoorClient(client cartographoor.CartographoorClient) { - m.cartographoorClient = client -} - func (m *Module) Init(_ []byte) error { return nil } func (m *Module) ApplyDefaults() {} func (m *Module) Validate() error { return nil } @@ -72,30 +62,10 @@ func (m *Module) Validate() error { return nil } func (m *Module) Start(_ context.Context) error { return nil } func (m *Module) Stop(_ context.Context) error { return nil } -// SandboxEnv injects EVM availability and per-network faucet URLs. +// SandboxEnv marks EVM availability for the sandbox. Faucet URLs are resolved +// server-side by the evm.faucet operation, so no per-network env is injected. func (m *Module) SandboxEnv() (map[string]string, error) { - env := map[string]string{"ETHPANDAOPS_EVM_AVAILABLE": "true"} - - if m.cartographoorClient == nil { - return env, nil - } - - faucets := make(map[string]string) - - for name, net := range m.cartographoorClient.GetActiveNetworks() { - if net.ServiceURLs != nil && net.ServiceURLs.Faucet != "" { - faucets[name] = net.ServiceURLs.Faucet - } - } - - if len(faucets) > 0 { - data, err := json.Marshal(faucets) - if err == nil { - env["ETHPANDAOPS_EVM_FAUCET_NETWORKS"] = string(data) - } - } - - return env, nil + return map[string]string{"ETHPANDAOPS_EVM_AVAILABLE": "true"}, nil } // Examples returns query examples for the evm module. @@ -142,7 +112,7 @@ func (m *Module) PythonAPIDocs() map[string]types.ModuleDoc { }, "faucet": { Signature: "faucet(network, address) -> str", - Description: "Return the PoW faucet URL for the network pre-filled with the given address. Open in a browser to request test ETH.", + Description: "Mine the network's PoW faucet and claim test ETH to address; returns the claim tx hash. Runs the full agent PoW flow server-side (no browser, WebSocket, or captcha). Requires panda auth.", }, }, }, diff --git a/modules/evm/python/evm.py b/modules/evm/python/evm.py index 729866f2..adaad98f 100644 --- a/modules/evm/python/evm.py +++ b/modules/evm/python/evm.py @@ -6,10 +6,10 @@ from __future__ import annotations -import json import os from typing import Any +from ethpandaops import _runtime from ethpandaops.ethnode import execution_rpc as _rpc # --------------------------------------------------------------------------- @@ -55,9 +55,6 @@ for _name, _byte in _OPS.items(): _OPS_BY_BYTE.setdefault(_byte, _name) # first name wins (e.g. KECCAK256 over SHA3) -_FAUCETS: dict[str, str] = json.loads(os.environ.get("ETHPANDAOPS_EVM_FAUCET_NETWORKS", "{}")) - - def _require_available() -> None: if not os.environ.get("ETHPANDAOPS_EVM_AVAILABLE", "").strip(): raise ValueError("EVM module is not enabled; ethnode datasource is required.") @@ -366,16 +363,18 @@ def wallet(private_key: str | None = None) -> dict[str, str]: # --------------------------------------------------------------------------- def faucet(network: str, address: str) -> str: - """Return the PoW faucet URL for the network pre-filled with the given address. + """Mine the network's PoW faucet and claim test ETH to address. - Open this URL in a browser, complete the PoW challenge, and the address - receives test ETH. Source: https://github.com/pk910/PoWFaucet + Runs the full agent proof-of-work flow server-side — no browser, WebSocket, + or captcha — and returns the claim transaction hash once it confirms. + Requires panda auth (run 'panda auth login'). Source: + https://github.com/pk910/PoWFaucet """ - url = _FAUCETS.get(network) - if not url: - available = list(_FAUCETS.keys()) - raise ValueError( - f"No faucet known for network {network!r}. Available: {available or 'none'}" - ) - sep = "&" if "?" in url else "?" - return f"{url}{sep}address={address}" + _require_available() + result = _runtime.invoke_json( + "evm.faucet", + {"network": network, "address": address}, + ) + if not isinstance(result, dict) or not result.get("claim_hash"): + raise ValueError(f"faucet claim did not return a tx hash: {result!r}") + return result["claim_hash"] diff --git a/pkg/faucet/faucet.go b/pkg/faucet/faucet.go new file mode 100644 index 00000000..1cf7b9cf --- /dev/null +++ b/pkg/faucet/faucet.go @@ -0,0 +1,340 @@ +// Package faucet mines a PoWFaucet agent-REST challenge and claims the reward +// for a target address, with no browser, WebSocket, or captcha. +// +// It requires the faucet's PoW to be Argon2id (type 2) at version 19 — the +// standard variant implemented by golang.org/x/crypto/argon2 — so mining runs +// natively with no cgo and no external dependency. +package faucet + +import ( + "context" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "strings" + "time" + + "golang.org/x/crypto/argon2" +) + +// userAgent is set on every request: the faucet ingress rejects some default +// client user-agents with 403, so we always send an explicit one. +const userAgent = "panda-faucet/1" + +// pollInterval and pollAttempts bound the wait for on-chain claim confirmation. +const ( + pollInterval = 3 * time.Second + pollAttempts = 40 +) + +// Result is the outcome of a successful claim. +type Result struct { + Session string `json:"session"` + Target string `json:"target"` + ClaimHash string `json:"claim_hash"` + AmountWei string `json:"amount_wei"` +} + +// Client talks to a single PoWFaucet agent-REST endpoint. +type Client struct { + baseURL string + http *http.Client +} + +// New returns a Client for the given faucet base URL, e.g. +// https://faucet-agents..ethpandaops.io. +func New(baseURL string, httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + + return &Client{baseURL: strings.TrimRight(baseURL, "/"), http: httpClient} +} + +// Claim runs the full agent flow for address: start a session, mine PoW shares +// until the balance covers the minimum drop, close the session, submit the +// claim, and poll until the claim transaction confirms. It returns the claim +// transaction hash. +func (c *Client) Claim(ctx context.Context, address string) (*Result, error) { + session, err := c.startSession(ctx, address) + if err != nil { + return nil, err + } + + minClaim, err := c.minClaim(ctx, session) + if err != nil { + return nil, err + } + + if err := c.mine(ctx, session, minClaim); err != nil { + return nil, err + } + + if err := c.post(ctx, "/api/powCloseSession?session="+session, nil, nil); err != nil { + return nil, fmt.Errorf("closing session: %w", err) + } + + if err := c.post(ctx, "/api/claimReward", map[string]string{"session": session}, nil); err != nil { + return nil, fmt.Errorf("submitting claim: %w", err) + } + + return c.awaitClaim(ctx, session, address) +} + +// startSession opens a PoW session for the target address and returns its id. +func (c *Client) startSession(ctx context.Context, address string) (string, error) { + var resp struct { + Session string `json:"session"` + FailedCode string `json:"failedCode"` + FailedReason string `json:"failedReason"` + } + + if err := c.post(ctx, "/api/startSession", map[string]string{"addr": address}, &resp); err != nil { + return "", fmt.Errorf("starting session: %w", err) + } + + if resp.Session == "" { + reason := resp.FailedReason + if reason == "" { + reason = resp.FailedCode + } + + return "", fmt.Errorf("faucet refused session: %s", reason) + } + + return resp.Session, nil +} + +// minClaim reads the minimum claimable amount (wei) for the session. +func (c *Client) minClaim(ctx context.Context, session string) (*big.Int, error) { + var resp struct { + MinClaim json.Number `json:"minClaim"` + } + + if err := c.get(ctx, "/api/getFaucetConfig?session="+session, &resp); err != nil { + return nil, fmt.Errorf("reading faucet config: %w", err) + } + + minClaim, ok := new(big.Int).SetString(resp.MinClaim.String(), 10) + if !ok || minClaim.Sign() <= 0 { + // Fall back to 1 ETH if the faucet does not advertise a minimum. + minClaim, _ = new(big.Int).SetString("1000000000000000000", 10) + } + + return minClaim, nil +} + +// powChallenge is a single mining assignment from the faucet. +type powChallenge struct { + Algo string `json:"algo"` + Params struct { + Type int `json:"type"` + Version int `json:"version"` + TimeCost uint32 `json:"timeCost"` + MemoryCost uint32 `json:"memoryCost"` + Parallelization uint8 `json:"parallelization"` + KeyLength uint32 `json:"keyLength"` + } `json:"params"` + Difficulty int `json:"difficulty"` + Preimage string `json:"preimage"` + NonceStart uint64 `json:"nonceStart"` + NonceCount uint64 `json:"nonceCount"` +} + +// mine fetches challenges and submits valid shares until the session balance +// reaches minClaim. +func (c *Client) mine(ctx context.Context, session string, minClaim *big.Int) error { + balance := new(big.Int) + + for balance.Cmp(minClaim) < 0 { + if err := ctx.Err(); err != nil { + return err + } + + var ch powChallenge + if err := c.get(ctx, "/api/powChallenge?session="+session, &ch); err != nil { + return fmt.Errorf("fetching challenge: %w", err) + } + + if err := supported(ch); err != nil { + return err + } + + salt, err := base64.StdEncoding.DecodeString(ch.Preimage) + if err != nil { + return fmt.Errorf("decoding preimage: %w", err) + } + + nonce, found, err := solve(ctx, ch, salt) + if err != nil { + return err + } + + if !found { + continue // no share in this range; the next challenge advances it + } + + var sub struct { + Valid bool `json:"valid"` + Balance string `json:"balance"` + Error string `json:"error"` + } + + body := map[string]any{"session": session, "nonce": nonce} + if err := c.post(ctx, "/api/powSubmit", body, &sub); err != nil { + return fmt.Errorf("submitting share: %w", err) + } + + if !sub.Valid { + return fmt.Errorf("faucet rejected share: %s", sub.Error) + } + + if _, ok := balance.SetString(sub.Balance, 10); !ok { + return fmt.Errorf("unparseable balance %q", sub.Balance) + } + } + + return nil +} + +// supported verifies the challenge is Argon2id at version 19, which is what +// x/crypto/argon2.IDKey computes. +func supported(ch powChallenge) error { + if ch.Algo != "argon2" || ch.Params.Type != 2 || ch.Params.Version != 19 { + return fmt.Errorf( + "unsupported PoW: algo=%q type=%d version=%d (need argon2/type=2/version=19)", + ch.Algo, ch.Params.Type, ch.Params.Version, + ) + } + + return nil +} + +// solve scans the challenge's nonce range for a hash that meets the difficulty +// target. ctx cancellation is checked periodically so long scans abort. +func solve(ctx context.Context, ch powChallenge, salt []byte) (uint64, bool, error) { + mask := difficultyMask(ch.Difficulty) + p := ch.Params + nonceBuf := make([]byte, 8) + + for i := uint64(0); i < ch.NonceCount; i++ { + if i%256 == 0 { + if err := ctx.Err(); err != nil { + return 0, false, err + } + } + + nonce := ch.NonceStart + i + binary.BigEndian.PutUint64(nonceBuf, nonce) + + hash := argon2.IDKey(nonceBuf, salt, p.TimeCost, p.MemoryCost, p.Parallelization, p.KeyLength) + if hex.EncodeToString(hash)[:len(mask)] <= mask { + return nonce, true, nil + } + } + + return 0, false, nil +} + +// difficultyMask returns the hex prefix a hash must be <= to satisfy the +// difficulty. It mirrors PoWValidatorWorker.getDifficultyMask exactly. +func difficultyMask(difficulty int) string { + byteCount := difficulty/8 + 1 + bitCount := difficulty - (byteCount-1)*8 + maxValue := 1 << (8 - bitCount) + + return fmt.Sprintf("%0*x", byteCount*2, maxValue) +} + +// awaitClaim polls session status until the claim confirms or fails. +func (c *Client) awaitClaim(ctx context.Context, session, address string) (*Result, error) { + for attempt := 0; attempt < pollAttempts; attempt++ { + var st struct { + Status string `json:"status"` + ClaimStatus string `json:"claimStatus"` + ClaimHash string `json:"claimHash"` + Balance string `json:"balance"` + } + + if err := c.get(ctx, "/api/getSessionStatus?session="+session, &st); err != nil { + return nil, fmt.Errorf("polling claim: %w", err) + } + + switch st.ClaimStatus { + case "confirmed": + return &Result{Session: session, Target: address, ClaimHash: st.ClaimHash, AmountWei: st.Balance}, nil + case "failed": + return nil, fmt.Errorf("claim failed (tx %s)", st.ClaimHash) + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(pollInterval): + } + } + + return nil, fmt.Errorf("claim not confirmed after %d polls", pollAttempts) +} + +// get performs a GET and decodes the JSON body into out (may be nil). +func (c *Client) get(ctx context.Context, path string, out any) error { + return c.do(ctx, http.MethodGet, path, nil, out) +} + +// post performs a POST with a JSON body and decodes the response into out. +func (c *Client) post(ctx context.Context, path string, body, out any) error { + return c.do(ctx, http.MethodPost, path, body, out) +} + +func (c *Client) do(ctx context.Context, method, path string, body, out any) error { + var reader io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + return err + } + + reader = strings.NewReader(string(encoded)) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) + if err != nil { + return err + } + + req.Header.Set("User-Agent", userAgent) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("faucet returned %d: %s", resp.StatusCode, strings.TrimSpace(string(data))) + } + + if out == nil { + return nil + } + + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + return nil +} diff --git a/pkg/faucet/faucet_live_test.go b/pkg/faucet/faucet_live_test.go new file mode 100644 index 00000000..e6084ece --- /dev/null +++ b/pkg/faucet/faucet_live_test.go @@ -0,0 +1,36 @@ +package faucet + +import ( + "context" + "os" + "testing" + "time" +) + +// TestClaimLive runs a real claim against a live faucet. It is guarded by env +// vars so it never runs in CI by default: +// +// FAUCET_LIVE_URL=https://faucet-agents..ethpandaops.io \ +// FAUCET_LIVE_ADDR=0x... \ +// go test -run TestClaimLive -v -timeout 6m ./pkg/faucet +func TestClaimLive(t *testing.T) { + url := os.Getenv("FAUCET_LIVE_URL") + addr := os.Getenv("FAUCET_LIVE_ADDR") + if url == "" || addr == "" { + t.Skip("set FAUCET_LIVE_URL and FAUCET_LIVE_ADDR to run the live e2e") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + res, err := New(url, nil).Claim(ctx, addr) + if err != nil { + t.Fatalf("live claim: %v", err) + } + + if res.ClaimHash == "" { + t.Fatal("claim returned no transaction hash") + } + + t.Logf("claimed to %s: tx %s (%s wei)", res.Target, res.ClaimHash, res.AmountWei) +} diff --git a/pkg/faucet/faucet_test.go b/pkg/faucet/faucet_test.go new file mode 100644 index 00000000..cdb18ca4 --- /dev/null +++ b/pkg/faucet/faucet_test.go @@ -0,0 +1,186 @@ +package faucet + +import ( + "context" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "golang.org/x/crypto/argon2" +) + +// mockFaucet is a faithful in-memory PoWFaucet agent-REST server: it advertises +// Argon2id/v19 params and validates each submitted share by recomputing the +// hash exactly as the real faucet does, so a share that does not meet the +// difficulty target is rejected. This proves the client mines correctly. +type mockFaucet struct { + difficulty int + timeCost uint32 + memoryCost uint32 + keyLength uint32 + preimage []byte // raw preimage bytes + shareReward int64 + minClaim int64 + claimHash string + + balance int64 + nonceCursor uint64 + claimed bool +} + +func (m *mockFaucet) handler() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/api/startSession", func(w http.ResponseWriter, _ *http.Request) { + writeJSONResp(w, map[string]any{"session": "sess-1"}) + }) + + mux.HandleFunc("/api/getFaucetConfig", func(w http.ResponseWriter, _ *http.Request) { + writeJSONResp(w, map[string]any{"minClaim": m.minClaim}) + }) + + mux.HandleFunc("/api/powChallenge", func(w http.ResponseWriter, _ *http.Request) { + start := m.nonceCursor + m.nonceCursor += 100000 + + writeJSONResp(w, map[string]any{ + "algo": "argon2", + "params": map[string]any{ + "type": 2, "version": 19, "timeCost": m.timeCost, + "memoryCost": m.memoryCost, "parallelization": 1, "keyLength": m.keyLength, + }, + "difficulty": m.difficulty, + "preimage": base64.StdEncoding.EncodeToString(m.preimage), + "nonceStart": start, + "nonceCount": uint64(100000), + }) + }) + + mux.HandleFunc("/api/powSubmit", func(w http.ResponseWriter, r *http.Request) { + var body struct { + Nonce uint64 `json:"nonce"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, body.Nonce) + hash := argon2.IDKey(buf, m.preimage, m.timeCost, m.memoryCost, 1, m.keyLength) + mask := difficultyMask(m.difficulty) + + if hex.EncodeToString(hash)[:len(mask)] > mask { + writeJSONResp(w, map[string]any{"valid": false, "error": "does not meet difficulty target"}) + + return + } + + m.balance += m.shareReward + writeJSONResp(w, map[string]any{"valid": true, "balance": strconv.FormatInt(m.balance, 10)}) + }) + + mux.HandleFunc("/api/powCloseSession", func(w http.ResponseWriter, _ *http.Request) { + writeJSONResp(w, map[string]any{"status": "claimable"}) + }) + + mux.HandleFunc("/api/claimReward", func(w http.ResponseWriter, _ *http.Request) { + m.claimed = true + writeJSONResp(w, map[string]any{"claimStatus": "queue"}) + }) + + mux.HandleFunc("/api/getSessionStatus", func(w http.ResponseWriter, _ *http.Request) { + if !m.claimed { + writeJSONResp(w, map[string]any{"status": "claiming", "claimStatus": "pending"}) + + return + } + + writeJSONResp(w, map[string]any{ + "status": "finished", "claimStatus": "confirmed", + "claimHash": m.claimHash, "balance": strconv.FormatInt(m.balance, 10), + }) + }) + + return mux +} + +func writeJSONResp(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func TestClaim(t *testing.T) { + // Low difficulty + tiny argon2 params keep the test fast while still + // exercising real mining, submission, and difficulty validation. + mock := &mockFaucet{ + difficulty: 6, + timeCost: 1, + memoryCost: 512, + keyLength: 16, + preimage: []byte("12345678"), + shareReward: 500_000_000_000_000_000, // 0.5 ETH + minClaim: 1_000_000_000_000_000_000, // 1 ETH -> needs 2 shares + claimHash: "0xabc123", + } + + srv := httptest.NewServer(mock.handler()) + defer srv.Close() + + res, err := New(srv.URL, srv.Client()).Claim(context.Background(), "0xTarget") + if err != nil { + t.Fatalf("Claim: %v", err) + } + + if res.ClaimHash != mock.claimHash { + t.Fatalf("claim hash = %q, want %q", res.ClaimHash, mock.claimHash) + } + + if !mock.claimed { + t.Fatal("faucet was never asked to claim") + } + + if mock.balance < mock.minClaim { + t.Fatalf("mined balance %d below minClaim %d", mock.balance, mock.minClaim) + } +} + +func TestSupportedRejectsNonStandardParams(t *testing.T) { + var ch powChallenge + ch.Algo = "argon2" + ch.Params.Type = 0 // Argon2d — not what x/crypto computes + ch.Params.Version = 19 + + if err := supported(ch); err == nil { + t.Fatal("expected Argon2d (type 0) to be rejected") + } + + ch.Params.Type = 2 + ch.Params.Version = 13 // non-standard version + + if err := supported(ch); err == nil { + t.Fatal("expected version 13 to be rejected") + } + + ch.Params.Version = 19 + if err := supported(ch); err != nil { + t.Fatalf("Argon2id/v19 should be supported: %v", err) + } +} + +func TestDifficultyMask(t *testing.T) { + for _, tc := range []struct { + difficulty int + want string + }{ + {10, "0040"}, + {14, "0004"}, + {8, "0100"}, + } { + if got := difficultyMask(tc.difficulty); got != tc.want { + t.Errorf("difficultyMask(%d) = %q, want %q", tc.difficulty, got, tc.want) + } + } +} diff --git a/pkg/server/operations_dispatch.go b/pkg/server/operations_dispatch.go index 5a2ef261..293fdb6a 100644 --- a/pkg/server/operations_dispatch.go +++ b/pkg/server/operations_dispatch.go @@ -23,6 +23,7 @@ func (s *service) dispatchOperation(operationID string, w http.ResponseWriter, r s.handleSpecsOperation, s.handleBlockArchiveOperation, s.handleNetworkOperation, + s.handleEVMOperation, } { if handler(operationID, w, r) { return true diff --git a/pkg/server/operations_evm.go b/pkg/server/operations_evm.go new file mode 100644 index 00000000..274d0ed9 --- /dev/null +++ b/pkg/server/operations_evm.go @@ -0,0 +1,94 @@ +package server + +import ( + "context" + "net/http" + "time" + + "github.com/ethpandaops/panda/pkg/faucet" + "github.com/ethpandaops/panda/pkg/operations" +) + +// faucetClaimTimeout bounds a single claim so a slow or stuck faucet cannot +// hang the request indefinitely. Argon2id/16MiB mining plus on-chain +// confirmation comfortably fits within this window. +const faucetClaimTimeout = 5 * time.Minute + +func (s *service) handleEVMOperation(operationID string, w http.ResponseWriter, r *http.Request) bool { + switch operationID { + case "evm.faucet": + s.handleEVMFaucet(w, r) + default: + return false + } + + return true +} + +// handleEVMFaucet mines the network's PoW faucet and claims test ETH to the +// given address, returning the claim transaction hash. It requires an +// authenticated panda session: the faucet dispenses real (testnet) funds, so +// use is gated behind 'panda auth login'. +func (s *service) handleEVMFaucet(w http.ResponseWriter, r *http.Request) { + if s.credentials == nil || !s.credentials.Status().Authenticated { + writeAPIError(w, http.StatusUnauthorized, + "panda auth required to use the faucet: run 'panda auth login' first") + + return + } + + if s.cartographoorClient == nil { + writeAPIError(w, http.StatusServiceUnavailable, "network inventory not available") + + return + } + + req, err := decodeOperationRequest(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + + return + } + + network, err := requiredStringArg(req.Args, "network") + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + + return + } + + address, err := requiredStringArg(req.Args, "address") + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + + return + } + + net, ok := s.cartographoorClient.GetNetwork(network) + if !ok { + writeAPIError(w, http.StatusNotFound, "network not found: "+network+". Use network.list for current ids.") + + return + } + + if net.ServiceURLs == nil || net.ServiceURLs.Faucet == "" { + writeAPIError(w, http.StatusNotFound, "no faucet advertised for network "+network) + + return + } + + ctx, cancel := context.WithTimeout(r.Context(), faucetClaimTimeout) + defer cancel() + + result, err := faucet.New(net.ServiceURLs.Faucet, nil).Claim(ctx, address) + if err != nil { + writeAPIError(w, http.StatusBadGateway, "faucet claim failed: "+err.Error()) + + return + } + + writeOperationResponse(s.log, w, http.StatusOK, operations.Response{ + Kind: operations.ResultKindObject, + Data: result, + }) +} diff --git a/pkg/server/operations_evm_test.go b/pkg/server/operations_evm_test.go new file mode 100644 index 00000000..a7cbba5c --- /dev/null +++ b/pkg/server/operations_evm_test.go @@ -0,0 +1,79 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" + + authclient "github.com/ethpandaops/panda/pkg/auth/client" +) + +// authedService returns a network-operation service whose credential controller +// holds a valid (unexpired) token, so the auth gate lets the request through. +func authedService(t *testing.T) *service { + t.Helper() + + svc := newNetworkOperationService() + + ctrl, path := newTestController(t, &fakeAuthClient{}) + seedTokens(t, path, &authclient.Tokens{AccessToken: "at", ExpiresAt: time.Now().Add(time.Hour)}) + svc.credentials = ctrl + + return svc +} + +func TestFaucetRequiresAuth(t *testing.T) { + args := map[string]any{"network": "fusaka-devnet-3", "address": "0x1111111111111111111111111111111111111111"} + + t.Run("no credential controller", func(t *testing.T) { + svc := newNetworkOperationService() // credentials is nil + rec := httptest.NewRecorder() + + require.True(t, svc.handleEVMOperation("evm.faucet", rec, newNetworkOpRequest(t, args))) + require.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("controller present but not logged in", func(t *testing.T) { + svc := newNetworkOperationService() + ctrl, _ := newTestController(t, &fakeAuthClient{}) // no tokens seeded + svc.credentials = ctrl + require.False(t, ctrl.Status().Authenticated) + + rec := httptest.NewRecorder() + require.True(t, svc.handleEVMOperation("evm.faucet", rec, newNetworkOpRequest(t, args))) + require.Equal(t, http.StatusUnauthorized, rec.Code) + }) +} + +func TestFaucetAuthenticatedResolution(t *testing.T) { + t.Run("unknown network", func(t *testing.T) { + svc := authedService(t) + rec := httptest.NewRecorder() + + args := map[string]any{"network": "does-not-exist", "address": "0x1111111111111111111111111111111111111111"} + require.True(t, svc.handleEVMOperation("evm.faucet", rec, newNetworkOpRequest(t, args))) + require.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("network without a faucet endpoint", func(t *testing.T) { + // fusaka-devnet-3 advertises rpc/beacon/dora but no faucet URL. + svc := authedService(t) + rec := httptest.NewRecorder() + + args := map[string]any{"network": "fusaka-devnet-3", "address": "0x1111111111111111111111111111111111111111"} + require.True(t, svc.handleEVMOperation("evm.faucet", rec, newNetworkOpRequest(t, args))) + require.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("missing address arg", func(t *testing.T) { + svc := authedService(t) + rec := httptest.NewRecorder() + + args := map[string]any{"network": "fusaka-devnet-3"} + require.True(t, svc.handleEVMOperation("evm.faucet", rec, newNetworkOpRequest(t, args))) + require.Equal(t, http.StatusBadRequest, rec.Code) + }) +} From 64f282c3aa4cb7e73481a5d53fbe956358dd70db Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 2 Jul 2026 15:28:58 +0200 Subject: [PATCH 2/3] feat(faucet): route the faucet through the proxy (no public surface) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client-side auth check is meaningless if the faucet endpoint is open, so the faucet is now reachable only through the panda proxy: - pkg/proxy: add a /faucet/{network}/ passthrough that mirrors the ethnode handler — the proxy's auth middleware authenticates the caller, then it forwards to the network's credential-gated faucet host with basic auth the proxy alone holds. - pkg/faucet: introduce a Transport so the mining flow runs either directly (tests/CLI) or through the proxy. - evm.faucet op: route via proxyRequest(/faucet//...); the local auth check is now only a fast pre-check, the proxy is the real boundary. --- pkg/faucet/faucet.go | 94 ++++++++++++++------- pkg/proxy/handlers/faucet.go | 134 ++++++++++++++++++++++++++++++ pkg/proxy/handlers/faucet_test.go | 56 +++++++++++++ pkg/proxy/server.go | 11 +++ pkg/server/operations_evm.go | 72 +++++++++++----- pkg/server/operations_evm_test.go | 10 --- 6 files changed, 315 insertions(+), 62 deletions(-) create mode 100644 pkg/proxy/handlers/faucet.go create mode 100644 pkg/proxy/handlers/faucet_test.go diff --git a/pkg/faucet/faucet.go b/pkg/faucet/faucet.go index 1cf7b9cf..51efc778 100644 --- a/pkg/faucet/faucet.go +++ b/pkg/faucet/faucet.go @@ -7,6 +7,7 @@ package faucet import ( + "bytes" "context" "encoding/base64" "encoding/binary" @@ -40,20 +41,27 @@ type Result struct { AmountWei string `json:"amount_wei"` } -// Client talks to a single PoWFaucet agent-REST endpoint. +// Transport issues one faucet HTTP request and returns the response body and +// status code. It lets the mining flow run either against a faucet URL directly +// (tests, CLI) or through the panda proxy — the only authenticated network path +// in production, so the faucet needs no public surface. +type Transport interface { + Do(ctx context.Context, method, path string, body []byte) (respBody []byte, status int, err error) +} + +// Client drives the PoWFaucet agent-REST flow over a Transport. type Client struct { - baseURL string - http *http.Client + t Transport } -// New returns a Client for the given faucet base URL, e.g. -// https://faucet-agents..ethpandaops.io. -func New(baseURL string, httpClient *http.Client) *Client { - if httpClient == nil { - httpClient = &http.Client{Timeout: 30 * time.Second} - } +// NewWithTransport returns a Client that issues requests through t (e.g. the +// proxy-routed transport used by the evm.faucet server operation). +func NewWithTransport(t Transport) *Client { return &Client{t: t} } - return &Client{baseURL: strings.TrimRight(baseURL, "/"), http: httpClient} +// New returns a Client that talks directly to a faucet base URL, e.g. +// https://faucet-agents..ethpandaops.io. Used by tests and direct use. +func New(baseURL string, httpClient *http.Client) *Client { + return NewWithTransport(newHTTPTransport(baseURL, httpClient)) } // Claim runs the full agent flow for address: start a session, mine PoW shares @@ -293,48 +301,76 @@ func (c *Client) post(ctx context.Context, path string, body, out any) error { } func (c *Client) do(ctx context.Context, method, path string, body, out any) error { - var reader io.Reader + var payload []byte if body != nil { encoded, err := json.Marshal(body) if err != nil { return err } - reader = strings.NewReader(string(encoded)) + payload = encoded } - req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) + data, status, err := c.t.Do(ctx, method, path, payload) if err != nil { return err } - req.Header.Set("User-Agent", userAgent) - if body != nil { - req.Header.Set("Content-Type", "application/json") + if status < 200 || status >= 300 { + return fmt.Errorf("faucet returned %d: %s", status, strings.TrimSpace(string(data))) } - resp, err := c.http.Do(req) - if err != nil { - return err + if out == nil { + return nil } - defer func() { _ = resp.Body.Close() }() - data, err := io.ReadAll(resp.Body) + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + return nil +} + +// httpTransport talks to a faucet base URL directly. +type httpTransport struct { + baseURL string + http *http.Client +} + +func newHTTPTransport(baseURL string, httpClient *http.Client) *httpTransport { + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + + return &httpTransport{baseURL: strings.TrimRight(baseURL, "/"), http: httpClient} +} + +func (t *httpTransport) Do(ctx context.Context, method, path string, body []byte) ([]byte, int, error) { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + + req, err := http.NewRequestWithContext(ctx, method, t.baseURL+path, reader) if err != nil { - return err + return nil, 0, err } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("faucet returned %d: %s", resp.StatusCode, strings.TrimSpace(string(data))) + req.Header.Set("User-Agent", userAgent) + if body != nil { + req.Header.Set("Content-Type", "application/json") } - if out == nil { - return nil + resp, err := t.http.Do(req) + if err != nil { + return nil, http.StatusBadGateway, err } + defer func() { _ = resp.Body.Close() }() - if err := json.Unmarshal(data, out); err != nil { - return fmt.Errorf("decoding response: %w", err) + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err } - return nil + return data, resp.StatusCode, nil } diff --git a/pkg/proxy/handlers/faucet.go b/pkg/proxy/handlers/faucet.go new file mode 100644 index 00000000..a91ec312 --- /dev/null +++ b/pkg/proxy/handlers/faucet.go @@ -0,0 +1,134 @@ +package handlers + +import ( + "fmt" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "sync" + + "github.com/sirupsen/logrus" +) + +// FaucetConfig holds the basic-auth credential for the per-network agent PoW +// faucet. The faucet ingress is credential-gated (not open), and only the proxy +// holds this pair — so panda users can reach the faucet solely through the +// proxy, which authenticates them first. +type FaucetConfig struct { + Username string + Password string +} + +// FaucetHandler reverse-proxies agent PoW faucet requests to the network's +// faucet host, attaching the basic-auth credential. Path: /faucet/{network}/... +// It mirrors EthNodeHandler but has no per-instance segment (one faucet/network). +type FaucetHandler struct { + log logrus.FieldLogger + cfg FaucetConfig + mu sync.RWMutex + proxes map[string]*httputil.ReverseProxy +} + +// NewFaucetHandler creates a new faucet handler. +func NewFaucetHandler(log logrus.FieldLogger, cfg FaucetConfig) *FaucetHandler { + return &FaucetHandler{ + log: log.WithField("handler", "faucet"), + cfg: cfg, + proxes: make(map[string]*httputil.ReverseProxy, 8), + } +} + +// ServeHTTP handles /faucet/{network}/{rest...}, forwarding {rest} (and the +// query string) to the network's faucet. +func (h *FaucetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/faucet/") + if path == r.URL.Path { + http.Error(w, "invalid path: must start with /faucet/", http.StatusBadRequest) + + return + } + + parts := strings.SplitN(path, "/", 2) + network := parts[0] + + rest := "/" + if len(parts) == 2 && parts[1] != "" { + rest = "/" + parts[1] + } + + if !validSegment.MatchString(network) { + http.Error(w, "invalid network name: must match [a-z0-9-]", http.StatusBadRequest) + + return + } + + host := faucetUpstreamHost(network) + proxy := h.getOrCreateProxy(host) + + r.URL.Path = rest + + h.log.WithFields(logrus.Fields{ + "network": network, + "path": rest, + "method": r.Method, + "upstream": host, + }).Debug("Proxying faucet request") + + proxy.ServeHTTP(w, r) +} + +// faucetUpstreamHost returns the credential-gated faucet host for a network. +func faucetUpstreamHost(network string) string { + return fmt.Sprintf("faucet-agents.%s.ethpandaops.io", network) +} + +// getOrCreateProxy returns a cached reverse proxy for the host, creating one if needed. +func (h *FaucetHandler) getOrCreateProxy(host string) *httputil.ReverseProxy { + h.mu.RLock() + proxy, ok := h.proxes[host] + h.mu.RUnlock() + + if ok { + return proxy + } + + h.mu.Lock() + defer h.mu.Unlock() + + if proxy, ok = h.proxes[host]; ok { + return proxy + } + + targetURL := &url.URL{ + Scheme: "https", + Host: host, + } + + rp := &httputil.ReverseProxy{Transport: newProxyTransport(false)} + + cfg := h.cfg + rp.Rewrite = func(pr *httputil.ProxyRequest) { + pr.SetURL(targetURL) + pr.SetXForwarded() + + // Drop the caller's bearer; the upstream faucet uses basic auth. + pr.Out.Header.Del("Authorization") + + if cfg.Username != "" { + pr.Out.SetBasicAuth(cfg.Username, cfg.Password) + } + + pr.Out.Host = pr.Out.URL.Host + pr.Out.Header.Del("Host") + } + + rp.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) { + h.log.WithError(err).WithField("upstream", host).Error("Proxy error") + http.Error(w, fmt.Sprintf("proxy error: %v", err), http.StatusBadGateway) + } + + h.proxes[host] = rp + + return rp +} diff --git a/pkg/proxy/handlers/faucet_test.go b/pkg/proxy/handlers/faucet_test.go new file mode 100644 index 00000000..300a9e1d --- /dev/null +++ b/pkg/proxy/handlers/faucet_test.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "net/http/httputil" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFaucetUpstreamHost(t *testing.T) { + assert.Equal(t, + "faucet-agents.glamsterdam-devnet-6.ethpandaops.io", + faucetUpstreamHost("glamsterdam-devnet-6"), + ) +} + +// TestFaucetProxyRewrite verifies the Rewrite closure targets the credential- +// gated faucet host, strips the caller's bearer, and attaches basic auth. +func TestFaucetProxyRewrite(t *testing.T) { + h := NewFaucetHandler(logrus.New(), FaucetConfig{Username: "u", Password: "p"}) + host := faucetUpstreamHost("net-1") + + rp := h.getOrCreateProxy(host) + require.NotNil(t, rp.Rewrite, "handler must use Rewrite") + + in := httptest.NewRequest(http.MethodPost, "/api/startSession", nil) + in.Header.Set("Authorization", "Bearer caller-token") + pr := &httputil.ProxyRequest{In: in, Out: in.Clone(in.Context())} + + rp.Rewrite(pr) + + assert.Equal(t, "https", pr.Out.URL.Scheme) + assert.Equal(t, host, pr.Out.URL.Host) + assert.Equal(t, host, pr.Out.Host) + + // The caller's bearer is dropped and replaced with the faucet basic-auth. + user, pass, ok := pr.Out.BasicAuth() + assert.True(t, ok, "basic auth must be set") + assert.Equal(t, "u", user) + assert.Equal(t, "p", pass) + assert.NotContains(t, pr.Out.Header.Get("Authorization"), "caller-token") +} + +func TestFaucetServeHTTPValidation(t *testing.T) { + h := NewFaucetHandler(logrus.New(), FaucetConfig{}) + + for _, path := range []string{"/nope", "/faucet/", "/faucet/BAD_NET/api/x"} { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + assert.Equal(t, http.StatusBadRequest, rec.Code, "path %q should be rejected", path) + } +} diff --git a/pkg/proxy/server.go b/pkg/proxy/server.go index d4c8273e..6f627fe7 100644 --- a/pkg/proxy/server.go +++ b/pkg/proxy/server.go @@ -70,6 +70,7 @@ type server struct { prometheusHandler *handlers.PrometheusHandler lokiHandler *handlers.LokiHandler ethNodeHandler *handlers.EthNodeHandler + faucetHandler *handlers.FaucetHandler benchmarkoorHandler *handlers.BenchmarkoorHandler computeHandler *handlers.ComputeHandler embeddingService *EmbeddingService @@ -190,6 +191,12 @@ func newServer(log logrus.FieldLogger, cfg ServerConfig, hostURL, port string) ( if ethNodeConfig != nil { s.ethNodeHandler = handlers.NewEthNodeHandler(log, *ethNodeConfig) + // The agent faucet is served on the same credential-gated ethpandaops.io + // domain as the nodes, so it reuses the ethnode basic-auth credential. + s.faucetHandler = handlers.NewFaucetHandler(log, handlers.FaucetConfig{ + Username: ethNodeConfig.Username, + Password: ethNodeConfig.Password, + }) } if benchConfigs := cfg.ToBenchmarkoorHandlerConfigs(); len(benchConfigs) > 0 { @@ -324,6 +331,10 @@ func (s *server) registerRoutes() { s.handleSubtreeRoute("/execution", s.metricsMiddleware(chain(s.ethNodeHandler))) } + if s.faucetHandler != nil { + s.handleSubtreeRoute("/faucet", s.metricsMiddleware(chain(s.faucetHandler))) + } + if s.benchmarkoorHandler != nil { s.handleSubtreeRoute("/benchmarkoor", s.metricsMiddleware(chain(s.benchmarkoorHandler))) } diff --git a/pkg/server/operations_evm.go b/pkg/server/operations_evm.go index 274d0ed9..a8fe8a8a 100644 --- a/pkg/server/operations_evm.go +++ b/pkg/server/operations_evm.go @@ -1,19 +1,24 @@ package server import ( + "bytes" "context" + "io" "net/http" + "regexp" "time" "github.com/ethpandaops/panda/pkg/faucet" "github.com/ethpandaops/panda/pkg/operations" ) -// faucetClaimTimeout bounds a single claim so a slow or stuck faucet cannot -// hang the request indefinitely. Argon2id/16MiB mining plus on-chain -// confirmation comfortably fits within this window. +// faucetClaimTimeout bounds a single claim so a slow or stuck faucet cannot hang +// the request. Argon2id/16MiB mining plus on-chain confirmation fits comfortably. const faucetClaimTimeout = 5 * time.Minute +// faucetNetworkPattern guards the network segment used to build the proxy path. +var faucetNetworkPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$`) + func (s *service) handleEVMOperation(operationID string, w http.ResponseWriter, r *http.Request) bool { switch operationID { case "evm.faucet": @@ -25,10 +30,10 @@ func (s *service) handleEVMOperation(operationID string, w http.ResponseWriter, return true } -// handleEVMFaucet mines the network's PoW faucet and claims test ETH to the -// given address, returning the claim transaction hash. It requires an -// authenticated panda session: the faucet dispenses real (testnet) funds, so -// use is gated behind 'panda auth login'. +// handleEVMFaucet mines the network's PoW faucet and claims test ETH to address, +// returning the claim transaction hash. The faucet is reachable only through the +// panda proxy (which authenticates the request), so it has no public surface — +// the local auth check below is only a friendly fast-fail, not the boundary. func (s *service) handleEVMFaucet(w http.ResponseWriter, r *http.Request) { if s.credentials == nil || !s.credentials.Status().Authenticated { writeAPIError(w, http.StatusUnauthorized, @@ -37,12 +42,6 @@ func (s *service) handleEVMFaucet(w http.ResponseWriter, r *http.Request) { return } - if s.cartographoorClient == nil { - writeAPIError(w, http.StatusServiceUnavailable, "network inventory not available") - - return - } - req, err := decodeOperationRequest(r) if err != nil { writeAPIError(w, http.StatusBadRequest, err.Error()) @@ -57,30 +56,33 @@ func (s *service) handleEVMFaucet(w http.ResponseWriter, r *http.Request) { return } - address, err := requiredStringArg(req.Args, "address") - if err != nil { - writeAPIError(w, http.StatusBadRequest, err.Error()) + if !faucetNetworkPattern.MatchString(network) { + writeAPIError(w, http.StatusBadRequest, "invalid network name") return } - net, ok := s.cartographoorClient.GetNetwork(network) - if !ok { - writeAPIError(w, http.StatusNotFound, "network not found: "+network+". Use network.list for current ids.") + address, err := requiredStringArg(req.Args, "address") + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) return } - if net.ServiceURLs == nil || net.ServiceURLs.Faucet == "" { - writeAPIError(w, http.StatusNotFound, "no faucet advertised for network "+network) + if s.cartographoorClient != nil { + if _, ok := s.cartographoorClient.GetNetwork(network); !ok { + writeAPIError(w, http.StatusNotFound, "network not found: "+network+". Use network.list for current ids.") - return + return + } } ctx, cancel := context.WithTimeout(r.Context(), faucetClaimTimeout) defer cancel() - result, err := faucet.New(net.ServiceURLs.Faucet, nil).Claim(ctx, address) + client := faucet.NewWithTransport(&proxyFaucetTransport{s: s, network: network}) + + result, err := client.Claim(ctx, address) if err != nil { writeAPIError(w, http.StatusBadGateway, "faucet claim failed: "+err.Error()) @@ -92,3 +94,27 @@ func (s *service) handleEVMFaucet(w http.ResponseWriter, r *http.Request) { Data: result, }) } + +// proxyFaucetTransport routes faucet REST calls through the proxy's +// /faucet/{network}/ passthrough. The proxy authenticates the caller's bearer +// token and attaches the faucet credential, which never leaves the proxy. +type proxyFaucetTransport struct { + s *service + network string +} + +func (p *proxyFaucetTransport) Do(ctx context.Context, method, path string, body []byte) ([]byte, int, error) { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + + headers := http.Header{} + if body != nil { + headers.Set("Content-Type", "application/json") + } + + data, status, _, err := p.s.proxyRequest(ctx, method, "/faucet/"+p.network+path, reader, headers) + + return data, status, err +} diff --git a/pkg/server/operations_evm_test.go b/pkg/server/operations_evm_test.go index a7cbba5c..09e8cb7b 100644 --- a/pkg/server/operations_evm_test.go +++ b/pkg/server/operations_evm_test.go @@ -58,16 +58,6 @@ func TestFaucetAuthenticatedResolution(t *testing.T) { require.Equal(t, http.StatusNotFound, rec.Code) }) - t.Run("network without a faucet endpoint", func(t *testing.T) { - // fusaka-devnet-3 advertises rpc/beacon/dora but no faucet URL. - svc := authedService(t) - rec := httptest.NewRecorder() - - args := map[string]any{"network": "fusaka-devnet-3", "address": "0x1111111111111111111111111111111111111111"} - require.True(t, svc.handleEVMOperation("evm.faucet", rec, newNetworkOpRequest(t, args))) - require.Equal(t, http.StatusNotFound, rec.Code) - }) - t.Run("missing address arg", func(t *testing.T) { svc := authedService(t) rec := httptest.NewRecorder() From 44882170c31fd078cf45da5a23bbc6a55f48098e Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 2 Jul 2026 17:33:39 +0200 Subject: [PATCH 3/3] feat(faucet): per-user session rate limit at the proxy Behind the proxy every faucet request carries the proxy's IP, so the faucet's own per-IP protections collapse to one global bucket. Restore per-user fairness where the authenticated identity is known: cap startSession to 30/user/hour, keyed on the auth subject. Only startSession is counted; the many powChallenge/powSubmit calls a single claim makes pass through untouched. --- pkg/proxy/faucet_ratelimit.go | 99 ++++++++++++++++++++++++++++++ pkg/proxy/faucet_ratelimit_test.go | 71 +++++++++++++++++++++ pkg/proxy/server.go | 4 +- 3 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 pkg/proxy/faucet_ratelimit.go create mode 100644 pkg/proxy/faucet_ratelimit_test.go diff --git a/pkg/proxy/faucet_ratelimit.go b/pkg/proxy/faucet_ratelimit.go new file mode 100644 index 00000000..46b7e495 --- /dev/null +++ b/pkg/proxy/faucet_ratelimit.go @@ -0,0 +1,99 @@ +package proxy + +import ( + "context" + "net/http" + "strings" + "sync" + "time" +) + +// Behind the proxy every faucet request carries the proxy's IP, so the faucet's +// own per-IP concurrency/recurring limits collapse to a single global bucket. +// These restore per-user fairness at the proxy, which is the only party that +// knows the authenticated identity. +const ( + faucetSessionLimit = 30 + faucetSessionWindow = time.Hour +) + +// faucetSessionRateLimiter caps how many faucet sessions an authenticated user +// may start within a rolling window. +type faucetSessionRateLimiter struct { + limit int + window time.Duration + + mu sync.Mutex + seen map[string][]time.Time +} + +func newFaucetSessionRateLimiter(limit int, window time.Duration) *faucetSessionRateLimiter { + return &faucetSessionRateLimiter{ + limit: limit, + window: window, + seen: make(map[string][]time.Time), + } +} + +// allow records a session start for user at now and reports whether it is under +// the limit within the window. Timestamps older than the window are pruned. +func (l *faucetSessionRateLimiter) allow(user string, now time.Time) bool { + l.mu.Lock() + defer l.mu.Unlock() + + cutoff := now.Add(-l.window) + + kept := l.seen[user][:0] + for _, t := range l.seen[user] { + if t.After(cutoff) { + kept = append(kept, t) + } + } + + if len(kept) >= l.limit { + l.seen[user] = kept + + return false + } + + l.seen[user] = append(kept, now) + + return true +} + +// middleware rate-limits POST /faucet/{network}/api/startSession per +// authenticated user; every other faucet request passes through untouched (a +// single claim makes many powChallenge/powSubmit calls we must not count). +// Unauthenticated requests (auth disabled) are not limited here — the auth +// middleware is the gate in that case. +func (l *faucetSessionRateLimiter) middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/api/startSession") { + next.ServeHTTP(w, r) + + return + } + + if user := faucetUserKey(r.Context()); user != "" && !l.allow(user, time.Now()) { + http.Error(w, "faucet session rate limit exceeded for this user", http.StatusTooManyRequests) + + return + } + + next.ServeHTTP(w, r) + }) +} + +// faucetUserKey returns a stable identity for the authenticated caller. +func faucetUserKey(ctx context.Context) string { + user := GetAuthUser(ctx) + if user == nil { + return "" + } + + if user.Subject != "" { + return user.Subject + } + + return user.Username +} diff --git a/pkg/proxy/faucet_ratelimit_test.go b/pkg/proxy/faucet_ratelimit_test.go new file mode 100644 index 00000000..f6ff4843 --- /dev/null +++ b/pkg/proxy/faucet_ratelimit_test.go @@ -0,0 +1,71 @@ +package proxy + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestFaucetSessionRateLimiterAllow(t *testing.T) { + l := newFaucetSessionRateLimiter(2, time.Hour) + base := time.Unix(1_700_000_000, 0) + + for i := 0; i < 2; i++ { + if !l.allow("u1", base) { + t.Fatalf("start %d within the limit should be allowed", i+1) + } + } + + if l.allow("u1", base) { + t.Fatal("third start should be blocked (limit 2)") + } + + if !l.allow("u2", base) { + t.Fatal("a different user must not be affected") + } + + if !l.allow("u1", base.Add(2*time.Hour)) { + t.Fatal("start should be allowed again after the window elapses") + } +} + +func TestFaucetSessionRateLimiterMiddleware(t *testing.T) { + l := newFaucetSessionRateLimiter(1, time.Hour) + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + h := l.middleware(next) + + do := func(path, user string) int { + req := httptest.NewRequest(http.MethodPost, path, nil) + if user != "" { + req = req.WithContext(withAuthUser(req.Context(), &AuthUser{Subject: user})) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + return rec.Code + } + + // Non-startSession calls are never limited (a claim makes many of them). + if code := do("/faucet/net/api/powChallenge", "alice"); code != http.StatusOK { + t.Fatalf("powChallenge should pass, got %d", code) + } + + // startSession: first allowed, second over the limit → 429. + if code := do("/faucet/net/api/startSession", "alice"); code != http.StatusOK { + t.Fatalf("alice first start should pass, got %d", code) + } + if code := do("/faucet/net/api/startSession", "alice"); code != http.StatusTooManyRequests { + t.Fatalf("alice second start should be 429, got %d", code) + } + + // A different user has an independent budget. + if code := do("/faucet/net/api/startSession", "bob"); code != http.StatusOK { + t.Fatalf("bob first start should pass, got %d", code) + } + + // Unauthenticated (auth disabled) is not limited here — auth is the gate. + if code := do("/faucet/net/api/startSession", ""); code != http.StatusOK { + t.Fatalf("unauthenticated should pass through, got %d", code) + } +} diff --git a/pkg/proxy/server.go b/pkg/proxy/server.go index 6f627fe7..9278c49b 100644 --- a/pkg/proxy/server.go +++ b/pkg/proxy/server.go @@ -332,7 +332,9 @@ func (s *server) registerRoutes() { } if s.faucetHandler != nil { - s.handleSubtreeRoute("/faucet", s.metricsMiddleware(chain(s.faucetHandler))) + // chain (auth) runs first and sets the AuthUser the limiter keys on. + faucetLimiter := newFaucetSessionRateLimiter(faucetSessionLimit, faucetSessionWindow) + s.handleSubtreeRoute("/faucet", s.metricsMiddleware(chain(faucetLimiter.middleware(s.faucetHandler)))) } if s.benchmarkoorHandler != nil {