diff --git a/modules/evm/module.go b/modules/evm/module.go index aec0b4f6..dc15c2de 100644 --- a/modules/evm/module.go +++ b/modules/evm/module.go @@ -112,7 +112,7 @@ func (m *Module) PythonAPIDocs() map[string]types.ModuleDoc { }, "faucet": { Signature: "faucet(network, address) -> str", - 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.", + Description: "Mine the network's PoW faucet and claim test ETH to address; returns the claim tx hash once the transaction is on-chain, so the balance is readable as soon as it returns. 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 adaad98f..0f0761c3 100644 --- a/modules/evm/python/evm.py +++ b/modules/evm/python/evm.py @@ -7,6 +7,7 @@ from __future__ import annotations import os +import sys from typing import Any from ethpandaops import _runtime @@ -366,15 +367,26 @@ def faucet(network: str, address: str) -> str: """Mine the network's PoW faucet and claim test ETH to address. Runs the full agent proof-of-work flow server-side — no browser, WebSocket, - or captcha — and returns the claim transaction hash once it confirms. + or captcha — and waits for the claim transaction to land on-chain, so the + balance is readable as soon as this returns. If the transaction had not been + included yet, a warning is printed and the hash is still returned. Requires panda auth (run 'panda auth login'). Source: https://github.com/pk910/PoWFaucet """ _require_available() - result = _runtime.invoke_json( + result = _runtime.invoke_data( "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"] + + claim_hash = result["claim_hash"] + if not result.get("confirmed"): + print( + f"warning: faucet claim {claim_hash} was submitted but is not on-chain yet; " + f"the balance of {address} may lag — poll it before spending", + file=sys.stderr, + ) + + return claim_hash diff --git a/pkg/faucet/faucet.go b/pkg/faucet/faucet.go index 51efc778..843e4270 100644 --- a/pkg/faucet/faucet.go +++ b/pkg/faucet/faucet.go @@ -27,18 +27,23 @@ import ( // 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. +// pollInterval and pollAttempts bound the wait for the faucet to report the +// claim submitted. This is the faucet's own claimStatus, not chain state. const ( pollInterval = 3 * time.Second pollAttempts = 40 ) -// Result is the outcome of a successful claim. +// Result is the outcome of a successful claim. Confirmed and BlockNumber are +// filled in by the caller once the claim transaction has an on-chain receipt; +// the faucet flow itself only learns that the faucet broadcast it. type Result struct { - Session string `json:"session"` - Target string `json:"target"` - ClaimHash string `json:"claim_hash"` - AmountWei string `json:"amount_wei"` + Session string `json:"session"` + Target string `json:"target"` + ClaimHash string `json:"claim_hash"` + AmountWei string `json:"amount_wei"` + Confirmed bool `json:"confirmed"` + BlockNumber uint64 `json:"block_number,omitempty"` } // Transport issues one faucet HTTP request and returns the response body and @@ -66,8 +71,12 @@ func New(baseURL string, httpClient *http.Client) *Client { // 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. +// claim, and poll until the faucet reports the claim transaction submitted. It +// returns the claim transaction hash. +// +// The faucet flips claimStatus to "confirmed" once it has broadcast the +// transaction, which can precede inclusion — so Result.Confirmed is left false +// here. Callers with chain access should wait for the receipt themselves. func (c *Client) Claim(ctx context.Context, address string) (*Result, error) { session, err := c.startSession(ctx, address) if err != nil { diff --git a/pkg/server/operations_evm.go b/pkg/server/operations_evm.go index a8fe8a8a..a26b358a 100644 --- a/pkg/server/operations_evm.go +++ b/pkg/server/operations_evm.go @@ -3,11 +3,14 @@ package server import ( "bytes" "context" + "fmt" "io" "net/http" "regexp" "time" + "github.com/sirupsen/logrus" + "github.com/ethpandaops/panda/pkg/faucet" "github.com/ethpandaops/panda/pkg/operations" ) @@ -16,6 +19,24 @@ import ( // the request. Argon2id/16MiB mining plus on-chain confirmation fits comfortably. const faucetClaimTimeout = 5 * time.Minute +// The faucet reports a claim "confirmed" as soon as it has broadcast the +// transaction, which can precede inclusion — so the operation waits for the +// receipt itself before returning. +// +// The wait is deliberately short. Mining already burns most of the caller's +// budget (sandbox.timeout defaults to 60s) and the claim is not lost if the +// receipt is slow, so it is better to return an unconfirmed hash than to hold +// the request until the sandbox kills it. +const ( + faucetReceiptTimeout = 30 * time.Second + faucetReceiptPollInterval = 2 * time.Second +) + +// faucetReceiptInstance is the ethnode handler's sentinel for a network's +// load-balanced execution endpoint (rpc..ethpandaops.io), so the +// receipt wait does not need to know any individual node name. +const faucetReceiptInstance = "lb" + // 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]$`) @@ -89,12 +110,81 @@ func (s *service) handleEVMFaucet(w http.ResponseWriter, r *http.Request) { return } + if err := s.awaitFaucetReceipt(ctx, network, result); 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, }) } +// awaitFaucetReceipt polls the network's load-balanced execution RPC until the +// claim transaction has a receipt, recording the block it landed in. +// +// The claim is already paid for by the time this runs, so a missing receipt is +// never fatal: a slow chain or an unreachable execution endpoint leaves +// Confirmed false with the hash intact, rather than reporting a funded address +// as a failed claim. Only a receipt saying the transaction reverted is an error. +func (s *service) awaitFaucetReceipt(ctx context.Context, network string, result *faucet.Result) error { + ctx, cancel := context.WithTimeout(ctx, faucetReceiptTimeout) + defer cancel() + + var lastErr error + + for { + raw, _, err := s.ethNodeExecutionRPC( + ctx, network, faucetReceiptInstance, + "eth_getTransactionReceipt", []any{result.ClaimHash}, + ) + if err != nil { + lastErr = err + } else if receipt, ok := raw.(map[string]any); ok { + // Anything else (a JSON null) means "not mined yet"; keep polling. + return applyFaucetReceipt(result, receipt) + } + + select { + case <-ctx.Done(): + s.log.WithError(lastErr).WithFields(logrus.Fields{ + "network": network, + "claim_hash": result.ClaimHash, + }).Warn("Faucet claim submitted but not seen on-chain within the receipt wait") + + return nil + case <-time.After(faucetReceiptPollInterval): + } + } +} + +// applyFaucetReceipt records a mined claim on result, or reports a reverted one. +func applyFaucetReceipt(result *faucet.Result, receipt map[string]any) error { + // Pre-Byzantium receipts have no status field; treat only an explicit + // failure as a revert. + if status, ok := receipt["status"].(string); ok && status != "0x1" { + return fmt.Errorf("claim transaction %s reverted on-chain (status %s)", result.ClaimHash, status) + } + + blockHex, ok := receipt["blockNumber"].(string) + if !ok { + return fmt.Errorf("claim transaction %s receipt has no block number", result.ClaimHash) + } + + block, err := parseHexUint64(blockHex) + if err != nil { + return fmt.Errorf("claim transaction %s has an unparseable block number %q: %w", + result.ClaimHash, blockHex, err) + } + + result.Confirmed = true + result.BlockNumber = block + + return nil +} + // 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. diff --git a/pkg/server/operations_evm_test.go b/pkg/server/operations_evm_test.go index 09e8cb7b..35e1bd7b 100644 --- a/pkg/server/operations_evm_test.go +++ b/pkg/server/operations_evm_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" authclient "github.com/ethpandaops/panda/pkg/auth/client" + "github.com/ethpandaops/panda/pkg/faucet" ) // authedService returns a network-operation service whose credential controller @@ -67,3 +68,55 @@ func TestFaucetAuthenticatedResolution(t *testing.T) { require.Equal(t, http.StatusBadRequest, rec.Code) }) } + +// The faucet reports a claim confirmed once it has broadcast the transaction, +// which can precede inclusion — evm.faucet therefore waits for the receipt and +// reports the block. These cover how that receipt is interpreted. +func TestApplyFaucetReceipt(t *testing.T) { + const hash = "0xf6a59c5d523cd13bcf66ead42c3eacb6a346130d4a119787393fd6a1cd0817d4" + + t.Run("mined receipt confirms and records the block", func(t *testing.T) { + result := &faucet.Result{ClaimHash: hash} + + require.NoError(t, applyFaucetReceipt(result, map[string]any{ + "status": "0x1", + "blockNumber": "0x36ddf", + })) + require.True(t, result.Confirmed) + require.Equal(t, uint64(224735), result.BlockNumber) + }) + + t.Run("pre-Byzantium receipt without status still confirms", func(t *testing.T) { + result := &faucet.Result{ClaimHash: hash} + + require.NoError(t, applyFaucetReceipt(result, map[string]any{"blockNumber": "0x1"})) + require.True(t, result.Confirmed) + require.Equal(t, uint64(1), result.BlockNumber) + }) + + t.Run("reverted receipt is an error", func(t *testing.T) { + result := &faucet.Result{ClaimHash: hash} + + err := applyFaucetReceipt(result, map[string]any{"status": "0x0", "blockNumber": "0x36ddf"}) + require.ErrorContains(t, err, "reverted on-chain") + require.False(t, result.Confirmed) + }) + + t.Run("receipt without a block number is an error", func(t *testing.T) { + result := &faucet.Result{ClaimHash: hash} + + require.ErrorContains(t, applyFaucetReceipt(result, map[string]any{"status": "0x1"}), + "no block number") + require.False(t, result.Confirmed) + }) + + t.Run("unparseable block number is an error", func(t *testing.T) { + result := &faucet.Result{ClaimHash: hash} + + require.ErrorContains(t, applyFaucetReceipt(result, map[string]any{ + "status": "0x1", + "blockNumber": "0xzz", + }), "unparseable block number") + require.False(t, result.Confirmed) + }) +}